diff --git a/assets/js/backpex.js b/assets/js/backpex.js index a38ca5529..c1d764f3e 100644 --- a/assets/js/backpex.js +++ b/assets/js/backpex.js @@ -1,3 +1,4 @@ import * as Hooks from './hooks' export { Hooks } +export { BackpexPreferences } from './hooks/_preferences' diff --git a/assets/js/hooks/_preferences.js b/assets/js/hooks/_preferences.js new file mode 100644 index 000000000..bc7dde3df --- /dev/null +++ b/assets/js/hooks/_preferences.js @@ -0,0 +1,175 @@ +/** + * BackpexPreferences - Unified preference persistence + * + * Handles all preference writes to the server. Supports: + * - Direct calls from JS hooks: BackpexPreferences.set(key, value) + * - LiveView push_events: push_event("backpex:set_preference", %{key, value}) + * + * Features: + * - Immediate persistence with keepalive (survives page navigation) + * - Non-blocking async operation + * - Optional sessionStorage mirroring for hooks whose UI chrome is re-rendered + * from a session snapshot that LiveView freezes at websocket-connect time. + * See the `mirror: 'session'` option on `set/3` and the matching `get/2` + * below — and the "Writing a JS hook that persists preferences" section + * of the user-preferences guide for the full rationale. + */ + +// All mirrored values share this prefix so a devtools inspection of +// sessionStorage is legible and one call site can clear everything if needed. +const SESSION_PREFIX = 'backpex.prefs.' + +function sessionKey (key) { + return SESSION_PREFIX + key +} + +// Best-effort read. Returns the raw string, or null if sessionStorage is +// unavailable (private mode, disabled) or the key is absent. +function readSession (key) { + try { + return sessionStorage.getItem(sessionKey(key)) + } catch { + return null + } +} + +// Best-effort write. Silently drops writes if sessionStorage is unavailable +// or quota-exceeded — the HTTP POST is still fired and remains authoritative +// on the next fresh connect. +function writeSession (key, value) { + try { + sessionStorage.setItem(sessionKey(key), value) + } catch { + // sessionStorage may be unavailable (private mode, quota); best effort only + } +} + +const BackpexPreferences = { + endpointPath: null, + csrfToken: null, + + /** + * Initialize the preference manager. + * Called by the LiveView hook on mount. + */ + init (endpointPath) { + this.endpointPath = endpointPath + this.csrfToken = document.querySelector("meta[name='csrf-token']")?.content + }, + + /** + * Read a preference, preferring the sessionStorage mirror over the + * caller-provided fallback. Only meaningful for keys that were written + * with `{ mirror: 'session' }` — keys persisted on the server alone will + * always return `fallback` here. + * + * Booleans and numbers deserialize from their `String(value)` form; + * strings pass through; everything else round-trips through JSON. + * + * The fallback's runtime type drives deserialization, so callers always + * get a value of the same shape they passed in. + * + * @param {string} key - Dot-notation key (e.g., "global.sidebar_open") + * @param {boolean|number|string|object|null|undefined} fallback - Value to + * return when the mirror is absent or sessionStorage is unavailable. + * @returns {*} The stored value or `fallback`. + */ + get (key, fallback) { + const raw = readSession(key) + if (raw === null) return fallback + + if (typeof fallback === 'boolean') return raw === 'true' + if (typeof fallback === 'number') { + const n = Number(raw) + return Number.isNaN(n) ? fallback : n + } + if (typeof fallback === 'string') return raw + + // Objects, arrays, null, undefined fallbacks → treat the mirror as JSON. + try { + return JSON.parse(raw) + } catch { + return fallback + } + }, + + /** + * Set a preference value and persist immediately. + * Called directly by JS hooks or via LiveView push_event. + * + * When `opts.mirror === 'session'` the value is written to sessionStorage + * *before* the HTTP POST, so the client-authoritative state survives the + * hook re-mount that LiveView performs on `live_redirect` between + * LiveViews (the server reads its session snapshot from the websocket + * handshake, which is frozen at connect time and doesn't see writes the + * HTTP endpoint just committed to the cookie). + * + * `opts.mirror === false` (or omitting `opts` entirely) keeps the legacy + * behavior: HTTP POST only, no local mirror. This is the right choice + * whenever the server is the authoritative source on every render + * (e.g. a DB-backed preference read fresh from Ecto). + * + * @param {string} key - Dot-notation key (e.g., "global.theme") + * @param {any} value - Value to store + * @param {{ mirror?: 'session' | false }} [opts] + */ + set (key, value, opts = {}) { + if (opts.mirror === 'session') { + const serialized = (typeof value === 'string') + ? value + : (typeof value === 'boolean' || typeof value === 'number') + ? String(value) + : JSON.stringify(value) + writeSession(key, serialized) + } + + this.persist(key, value) + }, + + /** + * Persist a preference to the server immediately. + * Uses keepalive to ensure request completes even during page navigation. + */ + persist (key, value) { + if (!this.endpointPath) { + console.warn('BackpexPreferences: endpointPath not initialized') + return + } + if (!this.csrfToken) { + console.warn('BackpexPreferences: CSRF token not found') + return + } + + // Use keepalive to ensure request survives page navigation + fetch(this.endpointPath, { + method: 'POST', + keepalive: true, + headers: { + 'Content-Type': 'application/json', + 'x-csrf-token': this.csrfToken + }, + body: JSON.stringify({ key, value }) + }).catch(error => { + console.error('BackpexPreferences: failed to persist', error) + }) + } +} + +/** + * LiveView hook that initializes BackpexPreferences + * and listens for push_events from the server. + * + * Mount this hook on an element with data-preferences-path attribute. + */ +const BackpexPreferencesHook = { + mounted () { + BackpexPreferences.init(this.el.dataset.preferencesPath) + + this.handleEvent('backpex:set_preference', ({ key, value }) => { + BackpexPreferences.set(key, value) + }) + } +} + +export default BackpexPreferencesHook +export { BackpexPreferences } diff --git a/assets/js/hooks/_sidebar.js b/assets/js/hooks/_sidebar.js index 06682cd91..058500d9f 100644 --- a/assets/js/hooks/_sidebar.js +++ b/assets/js/hooks/_sidebar.js @@ -1,3 +1,18 @@ +import { BackpexPreferences } from './_preferences' + +// Sidebar state is persisted both to the cookie (for fresh connects) and to +// sessionStorage (for live_redirects). LiveView freezes the session at +// websocket-connect time, so a re-mount after `live_redirect` reads a stale +// cookie and the server re-renders the shell from its default. The +// sessionStorage mirror keeps the user's client-side choices authoritative +// until the next fresh connect re-seeds from the cookie. +// +// The mirror is handled by BackpexPreferences.get/set with +// `mirror: 'session'` — see assets/js/hooks/_preferences.js and the +// "Writing a JS hook that persists preferences" section of the user +// preferences guide. If you add another JS-driven UI-chrome preference, +// follow the same pattern instead of rolling your own sessionStorage layer. + /** * Manages sidebar open/close state for mobile and desktop and handles sidebar section expand/collapse. * @@ -5,7 +20,6 @@ * Mobile: sidebar hidden by default, overlays content when opened */ export default { - STORAGE_KEY: 'backpex-sidebar-open', FOCUSABLE_SELECTOR: 'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])', @@ -18,13 +32,26 @@ export default { // No sidebar slot rendered; hook has nothing to do. if (!this.sidebar || !this.toggleBtn) return - // State: mobile closed by default, desktop state from localStorage (default open) + // State: mobile closed by default. Desktop state prefers the + // sessionStorage mirror over the server-rendered data attribute — same + // live_redirect staleness reason as the section states below. this.mobileOpen = false - this.desktopOpen = this.loadDesktopState() + this.desktopOpen = BackpexPreferences.get( + 'global.sidebar_open', + this.el.dataset.sidebarOpen === 'true' + ) // Element focused before the mobile drawer was opened, for focus restore. this.previousFocus = null // Per-toggle click handlers, keyed off the toggle element (section dropdowns). this._sectionHandlers = new WeakMap() + // Client-authoritative section state. Populated per-section from the + // sessionStorage mirror in initializeSections(); unknown sections fall + // back to the server-rendered data-section-open there. Seeding from + // sessionStorage is what lets section state survive the hook re-mount + // LiveView performs on live_redirect between LiveViews (the + // websocket-frozen session the server re-renders from is stale, see + // the top-of-file comment). + this._sectionStates = {} // Track Tailwind's lg breakpoint via its CSS custom property so CSS // `lg:` utilities and this hook stay in sync if the user customizes it. @@ -57,14 +84,18 @@ export default { document.addEventListener('keydown', this._onKeydown) - // Initialize sidebar sections + // Initialize sidebar sections, then re-assert stored state over whatever + // the server just rendered (which may have been rendered from a stale + // session snapshot during a live_redirect). this.initializeSections() + this.applySectionStates() }, updated () { if (!this.sidebar || !this.toggleBtn) return this.applyState() this.initializeSections() + this.applySectionStates() }, destroyed () { @@ -73,6 +104,10 @@ export default { this.mediaQuery?.removeEventListener('change', this._onMediaChange) document.removeEventListener('keydown', this._onKeydown) + // Drop inert in case the hook is torn down while the mobile drawer is open + // so the main content doesn't stay unreachable across live_redirects. + this.main?.removeAttribute('inert') + const sections = this.el.querySelectorAll('[data-section-id]') sections.forEach((section) => { const toggle = section.querySelector('[data-menu-dropdown-toggle]') @@ -91,7 +126,9 @@ export default { handleToggle () { if (this.isDesktop()) { this.desktopOpen = !this.desktopOpen - this.saveDesktopState() + // mirror: 'session' writes sessionStorage first, then POSTs to the + // cookie for the next fresh connect. + BackpexPreferences.set('global.sidebar_open', this.desktopOpen, { mirror: 'session' }) } else { if (!this.mobileOpen) this.previousFocus = document.activeElement this.mobileOpen = !this.mobileOpen @@ -100,16 +137,6 @@ export default { if (!this.isDesktop() && this.mobileOpen) this.focusFirstInSidebar() }, - loadDesktopState () { - const stored = localStorage.getItem(this.STORAGE_KEY) - // Default to open if no stored value - return stored === null ? true : stored === 'true' - }, - - saveDesktopState () { - localStorage.setItem(this.STORAGE_KEY, this.desktopOpen.toString()) - }, - closeMobile () { const wasOpen = this.mobileOpen this.mobileOpen = false @@ -202,6 +229,10 @@ export default { this.sidebar.removeAttribute('role') this.sidebar.removeAttribute('aria-modal') } + + // aria-modal needs a matching inert region; the topbar and main content + // live inside #backpex-main, so inerting that element covers both. + this.main.toggleAttribute('inert', !isDesktop && this.mobileOpen) }, // Sidebar Sections @@ -210,27 +241,30 @@ export default { const sections = this.el.querySelectorAll('[data-section-id]') sections.forEach((section) => { - const sectionId = section.dataset.sectionId const toggle = section.querySelector('[data-menu-dropdown-toggle]') const content = section.querySelector('[data-menu-dropdown-content]') + // Hide sections without content if (!this.hasContent(content)) { - content.style.display = 'none' + section.style.display = 'none' return } - const isOpen = - localStorage.getItem(`sidebar-section-${sectionId}`) === 'true' - if (!isOpen) { - toggle.classList.remove('menu-dropdown-show') - toggle.setAttribute('aria-expanded', 'false') - content.style.display = 'none' - } else { - toggle.setAttribute('aria-expanded', 'true') - } - section.classList.remove('hidden') + // Prefer the sessionStorage mirror over the server-rendered attribute + // the first time we see a section: on a fresh websocket connect the + // cookie is authoritative (and the mirror matches), but on a re-mount + // after live_redirect the server re-rendered from a stale session + // snapshot and the mirror is the only source of the user's intent. + const id = section.dataset.sectionId + if (!(id in this._sectionStates)) { + this._sectionStates[id] = BackpexPreferences.get( + `global.sidebar_section.${id}`, + section.dataset.sectionOpen === 'true' + ) + } + const previous = this._sectionHandlers.get(toggle) if (previous) toggle.removeEventListener('click', previous) const handler = (e) => this.handleSectionToggle(e) @@ -239,6 +273,23 @@ export default { }) }, + // Re-apply the authoritative client-side open/closed state to the DOM. + // Called from updated() to overwrite whatever the server just rendered from + // a potentially-stale session snapshot after a live_redirect. + applySectionStates () { + for (const [id, open] of Object.entries(this._sectionStates)) { + const section = this.el.querySelector(`[data-section-id="${id}"]`) + if (!section) continue + const toggle = section.querySelector('[data-menu-dropdown-toggle]') + const content = section.querySelector('[data-menu-dropdown-content]') + if (!toggle || !content) continue + toggle.classList.toggle('menu-dropdown-show', open) + toggle.setAttribute('aria-expanded', String(open)) + content.style.display = open ? '' : 'none' + section.dataset.sectionOpen = String(open) + } + }, + hasContent (element) { if (!element || element.children.length === 0) return false for (const child of element.children) { @@ -263,6 +314,18 @@ export default { const isNowOpen = toggle.classList.contains('menu-dropdown-show') toggle.setAttribute('aria-expanded', isNowOpen.toString()) - localStorage.setItem(`sidebar-section-${sectionId}`, isNowOpen) + // Keep the data attribute in sync so future reconciliations read back + // the current user-intended state. + section.dataset.sectionOpen = String(isNowOpen) + this._sectionStates[sectionId] = isNowOpen + // Mirror the per-section boolean to sessionStorage (for live_redirect + // re-mounts) and POST it to the cookie (for the next fresh connect). + // The per-section key matches the flat form the server stores so + // Backpex.Preferences.get_map/3 can reconstruct the nested map. + BackpexPreferences.set( + `global.sidebar_section.${sectionId}`, + isNowOpen, + { mirror: 'session' } + ) } } diff --git a/assets/js/hooks/_theme_selector.js b/assets/js/hooks/_theme_selector.js index 94eb0fb95..455b342c1 100644 --- a/assets/js/hooks/_theme_selector.js +++ b/assets/js/hooks/_theme_selector.js @@ -1,56 +1,52 @@ +import { BackpexPreferences } from './_preferences' + /** * Hook for selecting a theme. + * + * Mounted on the inner `
` element + * rather than the surrounding dropdown wrapper: the `<.dropdown>` component + * hardcodes `phx-hook="BackpexDropdown"` on its root, so passing a second + * `phx-hook` via `@rest` produced a duplicate attribute that the browser + * silently dropped. Mounting on the form sidesteps the collision, lets + * `this.el` be the form directly, and scopes the change listener to it. + * + * Initial theme is server-rendered via the `data-theme` attribute on + * ``. Changes are persisted via BackpexPreferences. + * + * This hook deliberately does NOT use `mirror: 'session'` even though the + * server reads `global.theme` at LiveView mount from a potentially-stale + * session snapshot (see `_sidebar.js` for the full rationale). The reason: + * the user-visible theme is the `data-theme` attribute on ``, which + * lives outside the LiveView root and is therefore never re-rendered on + * live_redirect — a stale read only misleads the internal theme-selector + * radio's `checked` attribute for one paint until the user reopens the + * menu. Not worth the sessionStorage overhead. If we ever move theme state + * inside the LiveView-rendered tree, switch to mirror: 'session'. */ export default { mounted () { - const form = document.querySelector('#backpex-theme-selector-form') - const storedTheme = window.localStorage.getItem('backpexTheme') - - // Marking current theme as active - if (storedTheme != null) { - const activeThemeRadio = form.querySelector( - `input[name='theme-selector'][value='${storedTheme}']` - ) - activeThemeRadio.checked = true - } - - window.addEventListener('backpex:theme-change', this.handleThemeChange.bind(this)) + // Initial theme already applied via server-rendered data-theme attribute + // Just set up the change listener, scoped to the form element itself. + this.boundHandleThemeChange = this.handleThemeChange.bind(this) + this.el.addEventListener('backpex:theme-change', this.boundHandleThemeChange) }, - // Event listener that handles the theme changes and store - // the selected theme in the session and also in localStorage - async handleThemeChange () { - const form = document.querySelector('#backpex-theme-selector-form') - const csrfToken = document.querySelector("meta[name='csrf-token']").getAttribute('content') - const cookiePath = form.dataset.cookiePath - const selectedTheme = form.querySelector( + + handleThemeChange () { + const selectedTheme = this.el.querySelector( 'input[name="theme-selector"]:checked' ) if (selectedTheme) { - window.localStorage.setItem('backpexTheme', selectedTheme.value) - document.documentElement.setAttribute( - 'data-theme', - selectedTheme.value - ) - await fetch(cookiePath, { - body: `select_theme=${selectedTheme.value}`, - method: 'POST', - headers: { - 'Content-type': 'application/x-www-form-urlencoded', - 'x-csrf-token': csrfToken - } - }) - } - }, - // Call this from your app.js as soon as possible to minimize flashes with the old theme in some situations. - setStoredTheme () { - const storedTheme = window.localStorage.getItem('backpexTheme') + // Update DOM immediately (optimistic) + document.documentElement.setAttribute('data-theme', selectedTheme.value) - if (storedTheme != null) { - document.documentElement.setAttribute('data-theme', storedTheme) + // Persist to cookie via BackpexPreferences — no mirror needed, see + // the module-level comment above. + BackpexPreferences.set('global.theme', selectedTheme.value) } }, + destroyed () { - window.removeEventListener('backpex:theme-change', this.handleThemeChange.bind(this)) + this.el.removeEventListener('backpex:theme-change', this.boundHandleThemeChange) } } diff --git a/assets/js/hooks/index.js b/assets/js/hooks/index.js index 6db43d6e2..e680a5d96 100644 --- a/assets/js/hooks/index.js +++ b/assets/js/hooks/index.js @@ -1,8 +1,11 @@ export { default as BackpexCancelEntry } from './_cancel_entry' export { default as BackpexDragHover } from './_drag_hover' export { default as BackpexDropdown } from './_dropdown' +export { default as BackpexPreferencesHook } from './_preferences' export { default as BackpexSidebar } from './_sidebar' export { default as BackpexStickyActions } from './_sticky_actions' export { default as BackpexThemeSelector } from './_theme_selector' export { default as BackpexTooltip } from './_tooltip' export { default as BackpexCurrencyInput } from './_currency_input' + +export { BackpexPreferences } from './_preferences' diff --git a/demo/assets/js/app.js b/demo/assets/js/app.js index 0e44b879c..e8ebf80a1 100644 --- a/demo/assets/js/app.js +++ b/demo/assets/js/app.js @@ -23,11 +23,6 @@ topbar.config({ barColors: { 0: '#29d' }, shadowColor: 'rgba(0, 0, 0, .3)' }) window.addEventListener('phx:page-loading-start', _info => topbar.show(250)) window.addEventListener('phx:page-loading-stop', _info => topbar.hide()) -/** - * Theme Selector - */ -BackpexHooks.BackpexThemeSelector.setStoredTheme() - /** * phoenix_live_view */ diff --git a/demo/lib/demo_web/components/layouts.ex b/demo/lib/demo_web/components/layouts.ex index 81e1ae833..d983d61db 100644 --- a/demo/lib/demo_web/components/layouts.ex +++ b/demo/lib/demo_web/components/layouts.ex @@ -7,6 +7,8 @@ defmodule DemoWeb.Layouts do attr :flash, :map, required: true, doc: "the map of flash messages" attr :fluid?, :boolean, default: true, doc: "if the content uses full width" attr :current_url, :string, required: true, doc: "the current url" + attr :current_theme, :string, default: nil, doc: "the currently selected theme" + attr :sidebar_open, :boolean, default: true, doc: "initial sidebar open state" slot :inner_block, required: true diff --git a/demo/lib/demo_web/components/layouts/admin.html.heex b/demo/lib/demo_web/components/layouts/admin.html.heex index f92e69bcf..ec53b5a53 100644 --- a/demo/lib/demo_web/components/layouts/admin.html.heex +++ b/demo/lib/demo_web/components/layouts/admin.html.heex @@ -1,9 +1,14 @@ - + <:topbar>
Short Links - + <:label>Blog Posts diff --git a/demo/lib/demo_web/components/layouts/root.html.heex b/demo/lib/demo_web/components/layouts/root.html.heex index 5fe58c294..722b0ebd3 100644 --- a/demo/lib/demo_web/components/layouts/root.html.heex +++ b/demo/lib/demo_web/components/layouts/root.html.heex @@ -1,5 +1,5 @@ - + diff --git a/demo/lib/demo_web/live/post_live.ex b/demo/lib/demo_web/live/post_live.ex index f34b7683f..b1994c48f 100644 --- a/demo/lib/demo_web/live/post_live.ex +++ b/demo/lib/demo_web/live/post_live.ex @@ -7,7 +7,8 @@ defmodule DemoWeb.PostLive do create_changeset: &Demo.Post.create_changeset/3 ], fluid?: true, - save_and_continue_button?: true + save_and_continue_button?: true, + persist: [:order, :filters, :columns] import Ecto.Query, warn: false diff --git a/demo/lib/demo_web/router.ex b/demo/lib/demo_web/router.ex index b15cc753e..40c444fc1 100644 --- a/demo/lib/demo_web/router.ex +++ b/demo/lib/demo_web/router.ex @@ -11,7 +11,6 @@ defmodule DemoWeb.Router do plug :put_root_layout, {DemoWeb.Layouts, :root} plug :protect_from_forgery plug :put_secure_browser_headers - plug Backpex.ThemeSelectorPlug end scope "/", DemoWeb do diff --git a/demo/test/demo_web/browser/sidebar_browser_test.exs b/demo/test/demo_web/browser/sidebar_browser_test.exs new file mode 100644 index 000000000..bccb21dba --- /dev/null +++ b/demo/test/demo_web/browser/sidebar_browser_test.exs @@ -0,0 +1,47 @@ +defmodule DemoWeb.Browser.SidebarBrowserTest do + use PhoenixTest.Playwright.Case, async: false + use DemoWeb, :verified_routes + + @moduletag :playwright + + # LiveView freezes the session at websocket-connect time, so a re-mount + # after live_redirect inside the same live_session reads a stale cookie + # and re-renders the sidebar (and its sections) from the default. The + # hook keeps the user's most recent toggle in sessionStorage and + # re-asserts it over the stale server render; these tests cover that. + + @blog_toggle ~s|[data-section-id="blog"] [data-menu-dropdown-toggle]| + @sidebar_toggle ~s|#backpex-sidebar-toggle| + + describe "sidebar section state across live_redirect" do + test "collapsed section stays collapsed after navigating to a sibling LiveResource", %{conn: conn} do + conn + |> visit(~p"/admin/posts") + |> assert_has("body .phx-connected") + |> assert_has(~s|#{@blog_toggle}[aria-expanded="true"]|) + |> click(@blog_toggle) + |> assert_has(~s|#{@blog_toggle}[aria-expanded="false"]|) + |> click(~s|a[href="/admin/invoices"]|) + |> assert_path("/admin/invoices") + |> assert_has(~s|#{@blog_toggle}[aria-expanded="false"]|) + end + end + + describe "sidebar open/closed state across live_redirect" do + # Collapsed sidebar becomes `inert`, so a user-simulated click on a + # sidebar link can't reach it. Fire a programmatic click via + # `HTMLElement.click()` — it bubbles through LiveView's delegated + # click handler and still triggers the live_redirect. + test "collapsed sidebar stays collapsed after navigating to a sibling LiveResource", %{conn: conn} do + conn + |> visit(~p"/admin/posts") + |> assert_has("body .phx-connected") + |> assert_has(~s|#{@sidebar_toggle}[aria-expanded="true"]|) + |> click(@sidebar_toggle) + |> assert_has(~s|#{@sidebar_toggle}[aria-expanded="false"]|) + |> evaluate(~s|document.querySelector('a[href="/admin/invoices"]').click()|) + |> assert_path("/admin/invoices") + |> assert_has(~s|#{@sidebar_toggle}[aria-expanded="false"]|) + end + end +end diff --git a/demo/test/demo_web/live/preferences_persistence_test.exs b/demo/test/demo_web/live/preferences_persistence_test.exs new file mode 100644 index 000000000..0eac9d9d3 --- /dev/null +++ b/demo/test/demo_web/live/preferences_persistence_test.exs @@ -0,0 +1,162 @@ +defmodule DemoWeb.Live.PreferencesPersistenceTest do + @moduledoc """ + End-to-end coverage for the `persist: [:order, :filters, :columns]` option on + `Backpex.LiveResource`. Mounts `DemoWeb.PostLive` (configured with all three + persistence kinds) and asserts that sort, filter, and column-toggle + interactions emit a `push_event` with the expected preference key and value + shape. + + The wire event name comes from `Backpex.Preferences.LiveView.event_name/0` + and the keys come from `Backpex.Preferences.Keys.{order,filters,columns}/1`, + so the test reflects the same contract the emitter uses. + """ + + use DemoWeb.ConnCase, async: false + + import Demo.EctoFactory + import Phoenix.LiveViewTest + + alias Backpex.Preferences.Keys, as: PrefKeys + alias Backpex.Preferences.LiveView, as: PrefLiveView + + @resource_mod DemoWeb.PostLive + + # assert_push_event expands to assert_receive, which pattern-matches the + # arguments. Bind the event name and key to module-level constants or local + # variables before the macro call so the pattern is literal-shaped. + @event_name PrefLiveView.event_name() + + describe "persist: [:order]" do + test "sort change via column-header click emits push_event with order key", %{conn: conn} do + insert(:post, title: "Alpha", published: true) + insert(:post, title: "Beta", published: true) + + {:ok, view, _html} = live(conn, ~p"/admin/posts?filters[published][]=published") + + # Click the Title column header — triggers a sort and routes through + # maybe_persist_order/2 which fires the push_event. + view + |> element("a", "Title") + |> render_click() + + expected_key = PrefKeys.order(@resource_mod) + + assert_push_event(view, @event_name, %{ + key: ^expected_key, + value: %{"by" => "title", "direction" => "asc"} + }) + end + end + + describe "persist: [:filters]" do + test "filter change emits push_event with filters key", %{conn: conn} do + insert(:post, title: "Published", published: true) + insert(:post, title: "Draft", published: false) + + # Mount with the default published-only filter applied. + {:ok, view, _html} = live(conn, ~p"/admin/posts?filters[published][]=published") + + # Toggle the filter to include not_published too — routes through + # the change-filter handler → apply_filter_change/2 → push_event. + view + |> form("form[phx-change='change-filter']", + filters: %{published: ["published", "not_published"]} + ) + |> render_change() + + expected_key = PrefKeys.filters(@resource_mod) + + # The LiveResource emits several filter-persistence events over the mount + # + change cycle. We care that at least one of them reflects the new + # two-value set and carries the filters key. + assert_push_event(view, @event_name, %{ + key: ^expected_key, + value: %{"published" => ["published", "not_published"]} + }) + end + + test "clear-filter emits push_event with empty map", %{conn: conn} do + insert(:post, title: "Published", published: true) + insert(:post, title: "Draft", published: false) + + # Mount with the default published-only filter applied. + {:ok, view, _html} = live(conn, ~p"/admin/posts?filters[published][]=published") + + # Click the filter badge's clear (×) button for the `published` filter. + # The URL collapses to no `filters[]` param, so the clear-filter handler + # itself must emit the empty-map push_event — apply_index can't infer the + # cleared intent from the URL alone. + # + # There are two buttons that fire `clear-filter` for the same field + # (the inline "clear" link and the indicator badge's × icon). Target + # the indicator explicitly via its aria-label. + view + |> element("button[aria-label='Clear Published? filter']") + |> render_click() + + expected_key = PrefKeys.filters(@resource_mod) + + assert_push_event(view, @event_name, %{key: ^expected_key, value: value}) + assert value == %{} + end + + test "persisted %{} filters suppress redirect to defaults on mount", %{conn: conn} do + # Simulates the round-trip after a user cleared every filter and + # navigated away: the preferences adapter holds an explicit empty map. + # On return, `apply_index` must treat that state as "user cleared + # everything" and skip the default-filter redirect. Without this, + # `maybe_redirect_to_default_filters` would see `query_options.filters + # == %{}` and re-apply the `:default` from the `published` filter, + # overwriting the user's persisted clear. + insert(:post, title: "Published Post", published: true) + insert(:post, title: "Draft Post", published: false) + + session = %{ + "backpex_preferences" => %{ + "resource" => %{"DemoWeb.PostLive" => %{"filters" => %{}}} + } + } + + conn = Plug.Test.init_test_session(conn, session) + + {:ok, _view, html} = live(conn, ~p"/admin/posts") + + # Both posts are visible → no `published` default was applied. + assert html =~ "Published Post" + assert html =~ "Draft Post" + end + + test "no persisted filters still triggers redirect to defaults on mount", %{conn: conn} do + # Pins the existing onboarding flow: a fresh user with nothing in the + # preferences store still gets the `:default` filter applied (and the + # URL rewritten to carry it), so the bug fix doesn't regress this path. + insert(:post, title: "Published Post", published: true) + insert(:post, title: "Draft Post", published: false) + + assert {:error, {:live_redirect, %{to: to}}} = live(conn, ~p"/admin/posts") + assert to =~ "filters[published][]=published" + end + end + + describe "persist: [:columns]" do + test "column toggle emits push_event with columns key", %{conn: conn} do + insert(:post, title: "Alpha", published: true) + + {:ok, view, _html} = live(conn, ~p"/admin/posts?filters[published][]=published") + + # Toggle the "title" column off. maybe_push_columns/3 emits the + # push_event with the full active-fields map. + view + |> element("input[phx-click='toggle_column'][phx-value-field='title']") + |> render_click() + + expected_key = PrefKeys.columns(@resource_mod) + + assert_push_event(view, @event_name, %{key: ^expected_key, value: value}) + + # title was just toggled, so it must now be false; other fields remain true. + assert is_map(value) + assert value["title"] == false + end + end +end diff --git a/guides/get_started/installation.md b/guides/get_started/installation.md index e46044577..d554ce3e7 100644 --- a/guides/get_started/installation.md +++ b/guides/get_started/installation.md @@ -107,7 +107,7 @@ Backpex ships with a formatter configuration. To use it, add Backpex to the list To make LiveResources accessible in your application, you first need to configure your router (`router.ex`). -Backpex needs to add a `backpex_cookies` route to your router. This route is used to set the cookies needed for a Backpex LiveResource. +Backpex needs a `backpex_preferences` route in your router. This route is used by Backpex to persist user preferences such as the active theme and sidebar state. Backpex provides a macro you can use to add the required routes to your router. Make sure to import `Backpex.Router` at the top of your router file or prefix the function calls. @@ -138,7 +138,12 @@ Although Backpex does not ship with a predefined layout component, it does provi To get you started quickly, we provide a layout component you can copy & paste into your application. Place it as a file in your `lib/myapp_web/templates/layout` directory. You can name it whatever you like, but we recommend using `admin.html.heex`. You can also use this component as the only layout in your application if your application consists of only an admin interface. This layout component uses the `Backpex.HTML.Layout.app_shell/1` component, which can be used to easily add an app shell layout to your application. ```heex - + <:topbar>
@@ -169,6 +174,8 @@ To get you started quickly, we provide a layout component you can copy & paste i
``` +These assigns (`@current_theme`, `@sidebar_open`, `@sidebar_section_states`) are populated by `Backpex.InitAssigns` — see [Add resource routes](#add-resource-routes) below for setup. + In addition we recommend to add a bodyless function definition and to configure declarative assigns for your layout component. ```elixir @@ -180,6 +187,8 @@ defmodule MyAppWeb.Layouts do attr :flash, :map, required: true, doc: "the map of flash messages" attr :fluid?, :boolean, default: true, doc: "if the content uses full width" attr :current_url, :string, required: true, doc: "the current url" + attr :sidebar_open, :boolean, default: true, doc: "initial sidebar open state" + attr :sidebar_section_states, :map, default: %{}, doc: "map of sidebar section open states" slot :inner_block, required: true @@ -361,12 +370,17 @@ You probably also want to add link to your created LiveResource in the sidebar. If you copied the provided layout component from [the section above](#create-a-default-admin-layout), you can just use the `sidebar_item/1` component inside the sidebar slot like this: ```heex - + <:topbar> <:sidebar> - + <.icon name="hero-book-open" class="size-5" /> Posts @@ -376,7 +390,23 @@ If you copied the provided layout component from [the section above](#create-a-d ``` -Note that Backpex also provides the `Backpex.HTML.Layout.sidebar_item/1` component to create nested sidebar sections. +You can also group sidebar items into collapsible sections using the `Backpex.HTML.Layout.sidebar_section/1` component: + +```heex +<:sidebar> + + <:label>Blog + + <.icon name="hero-book-open" class="size-5" /> Posts + + + <.icon name="hero-tag" class="size-5" /> Categories + + + +``` + +The `sidebar_section_states` assign (provided by `Backpex.InitAssigns`) tracks which sections are expanded or collapsed, and persists user preferences across page loads. ### Configure a default route @@ -538,13 +568,13 @@ First, you need to add the themes to your stylesheet. You can add the themes to The full list of themes can be found at the [daisyUI website](https://daisyui.com/docs/themes/). -**2. Set the assign and the default daisyUI theme in your root layout.** +**2. Set the default daisyUI theme in your root layout.** -We fetch the theme from the assigns and set the `data-theme` attribute on the `html` tag. If no theme is set, we default to the `light` theme. +Set the `data-theme` attribute on the `html` tag using the `@current_theme` assign. The `Backpex.InitAssigns` hook (which you should already have configured in your router) will automatically populate this assign from the user's saved preference. ```heex # root.html.heex - + ... ``` @@ -558,31 +588,23 @@ If you just want to use a single theme, you can set the `data-theme` attribute t ``` -**3. Add `Backpex.ThemeSelectorPlug` to the pipeline in the router** - -To add the saved theme to the assigns, you can add the `Backpex.ThemeSelectorPlug` to the pipeline in your router. This plug will fetch the selected theme from the session and put it in the assigns. - -```elixir -# router.ex - pipeline :browser do - ... - # Add this plug - plug Backpex.ThemeSelectorPlug - end -``` - -**4. Add the theme selector component to the app shell** +**3. Add the theme selector component to the app shell** You can add a theme selector to your layout component to allow users to change the theme. The following example shows how to add a theme selector to the `admin` component. The list of themes should match the themes you added to your stylesheet. ```heex - + <:topbar>
- <%= @inner_content %> + {render_slot(@inner_block)}
``` -**5. Set selected theme** - -To set the selected theme as soon as possible, you can run this function inside your `app.js`: - -```javascript -import { Hooks as BackpexHooks } from 'backpex'; -// ... -BackpexHooks.BackpexThemeSelector.setStoredTheme() -``` - -This will minimize flashes with the old theme in some situations. +Theme changes are automatically persisted to the session cookie, so users will see their selected theme on subsequent page loads without any flickering. diff --git a/guides/live_resource/user-preferences.md b/guides/live_resource/user-preferences.md new file mode 100644 index 000000000..4e9ea501c --- /dev/null +++ b/guides/live_resource/user-preferences.md @@ -0,0 +1,1035 @@ +# User Preferences + +Backpex persists UI state — theme, sidebar, per-resource column visibility, +metric toggles, and anything you want to add — through a pluggable adapter +layer. Out of the box everything lives in the Phoenix session (zero config +required). Configure a database adapter for one prefix and the rest stay in +the session; every setting is routed independently. + +## How It Works + +``` + INITIAL PAGE LOAD +┌──────────────────────────────────────────────────────────────────────────┐ +│ │ +│ Browser ──cookie──▶ Backpex.InitAssigns │ +│ │ │ +│ ▼ │ +│ Backpex.Preferences.get/3 │ +│ │ │ +│ ▼ │ +│ Router (longest-prefix match) │ +│ │ │ │ +│ global.* resource.* │ +│ │ │ │ +│ ▼ ▼ │ +│ Session adapter Ecto adapter (user-provided) │ +│ │ │ │ +│ └──────┬───────┘ │ +│ ▼ │ +│ Server-rendered HTML with correct state │ +│ │ +└──────────────────────────────────────────────────────────────────────────┘ + + USER CHANGES STATE +┌──────────────────────────────────────────────────────────────────────────┐ +│ │ +│ JS toggle or LiveView push_event │ +│ │ │ +│ ▼ │ +│ BackpexPreferences.set(key, value) │ +│ │ │ +│ ▼ │ +│ POST /backpex_preferences (async, keepalive) │ +│ │ │ +│ ▼ │ +│ Backpex.PreferencesController → Preferences.put_batch/2 │ +│ │ │ +│ ▼ │ +│ Router → adapter(s) → side effects │ +│ │ │ +│ ▼ │ +│ Best-effort apply: {ok: true} or {ok: false, error: {key, reason}} │ +│ │ +└──────────────────────────────────────────────────────────────────────────┘ +``` + +**Key benefits:** + +- **No flicker.** The server renders initial state from the adapter on every + request, so the first paint is already correct. +- **Instant UI.** Writes are async (`keepalive: true`) — the browser never + blocks on persistence. +- **Storage is your call.** Per-browser session is the default; swap any + prefix onto a per-user database with a few lines of config. + +## Contracts + +Backpex dispatches every preference read and write through a +`%Backpex.Preferences.Context{}` carrying the current session **and** the +current `assigns` (controller `conn.assigns` on the write path, +`socket.assigns` on the LiveView read path). Adapters — and the identity +resolver they share — are expected to read from `ctx.assigns` first and fall +back to `ctx.session` only when the assigns view is empty. + +For that guarantee to hold, the host app must satisfy a handful of +ordering and content contracts. None of these are enforced at compile time, +so it is worth spelling them out explicitly. + +### Ordering: auth runs first + +- **LiveView read path.** `Backpex.InitAssigns` must be attached **after** + your app's authentication `on_mount` hook so that `socket.assigns` already + holds `:current_user` / `:current_scope` by the time preferences are read. + In a typical Phoenix 1.8 `live_session`: + + ```elixir + live_session :authenticated, + on_mount: [ + {MyAppWeb.UserAuth, :ensure_authenticated}, + Backpex.InitAssigns + ] do + # ... Backpex routes ... + end + ``` + + If the order is reversed, `InitAssigns` will see an empty `socket.assigns` + and your identity resolver will have to fall back to reading the raw + session token — defeating the point of threading assigns through. + +- **Controller write path.** The preferences controller is mounted behind + the standard browser pipeline. As long as your auth plug runs before + `Backpex.PreferencesController`, `conn.assigns` already contains the + authenticated user by the time `Preferences.put/4` or + `Preferences.put_batch/3` executes. This is true by construction of + `Plug.Conn.assigns` but worth stating. + +### The identity resolver receives a Context + +Your resolver gets a `%Backpex.Preferences.Context{}`, not a raw session. +Read from `ctx.assigns` first — it is the post-auth, freshest view. Fall +back to `ctx.session` only for edge cases where assigns cannot carry the +answer (e.g. a non-LiveView write path that bypasses your auth `on_mount` +but still sits behind the router's session + auth plug pipeline): + +```elixir +defmodule MyAppWeb.PreferencesIdentity do + alias Backpex.Preferences.Context + + # Primary: whatever your auth layer put on assigns. + def resolve(%Context{assigns: %{current_scope: %{user: %{id: id}}}}), do: id + def resolve(%Context{assigns: %{current_user: %{id: id}}}), do: id + + # Fallback: a raw session token. Useful when you truly only have a + # session on hand (background jobs, tests, hand-crafted calls). + def resolve(%Context{session: %{"user_token" => token}}) when is_binary(token) do + case MyApp.Accounts.get_user_by_session_token(token) do + %{id: id} -> id + _ -> :unidentified + end + end + + def resolve(_ctx), do: :unidentified +end +``` + +The resolver runs once per dispatcher call and its result is cached on the +context for the rest of that single dispatch (so one read never invokes it +twice). Keep it cheap all the same — every `Preferences.get/3` and +`Preferences.put/4` call triggers a fresh resolution. + +### Session key must survive `renew_session` + +Phoenix's `renew_session/1` helper (commonly called on login/logout to +rotate the session id) drops every session key unless explicitly preserved. +Backpex stores its session-backed preferences under +`Backpex.Preferences.session_key/0` (currently `"backpex_preferences"`) — if +you call `renew_session` in your auth flow, carry that key across: + +```elixir +def renew_session(conn) do + prefs = Plug.Conn.get_session(conn, Backpex.Preferences.session_key()) + + conn + |> configure_session(renew: true) + |> clear_session() + |> then(fn c -> + if prefs, do: put_session(c, Backpex.Preferences.session_key(), prefs), else: c + end) +end +``` + +DB-backed adapters are unaffected by `renew_session` — they key off the +user id, not the session. This note only matters for prefixes routed to +`Backpex.Preferences.Adapters.Session`. + +## Built-in preference keys + +Every key Backpex reads or writes is listed here. Third-party code should +prefix its own keys with `custom.` to avoid colliding with Backpex. + +| Key | Type | Read at | Written at | Opt-in? | +|--------------------------------------------|----------|------------------------------------------|---------------------------------------|-------------------------| +| `global.theme` | string | `Backpex.InitAssigns` | JS theme selector | always on | +| `global.sidebar_open` | boolean | `Backpex.InitAssigns` | JS sidebar toggle | always on | +| `global.sidebar_section.` | boolean | `Backpex.InitAssigns` (via `get_map/3`) | JS sidebar section toggle | always on | +| `resource::columns` | map | Index view mount | `toggle_column` event | `persist: [:columns]` | +| `resource::metrics_visible` | boolean | Index view mount | `toggle_metrics` event | always on | +| `resource::order` | map | Index view mount (fallback) | `handle_params` (on change) | `persist: [:order]` | +| `resource::filters` | map | Index view mount (fallback) | `handle_params` (on change) | `persist: [:filters]` | + +Keys with embedded module names use `:` as a separator so module-name dots +(e.g. `MyApp.MyLive`) don't create extra path segments. See +`Backpex.Preferences.Key`. + +## Reading preferences in your layout + +`Backpex.InitAssigns` already populates the assigns that the built-in layout +needs: + +```elixir +@current_theme # "light", "dark", ... +@sidebar_open # true | false +@sidebar_section_states # %{"blog" => true, "settings" => false} +``` + +```heex + + <:topbar> + + + <:sidebar> + + <:label>Blog + + + +``` + +## Storage adapters + +An adapter owns the "where" of preference storage. Backpex ships one +(`Backpex.Preferences.Adapters.Session`) and lets you plug in others per +prefix. `Backpex.Preferences` routes each call through the adapter configured +for the key's prefix. + +### Picking an adapter + +| If you want… | Use… | +|-----------------------------------------------------------------|-----------------------------------------------------| +| Zero config, per-browser state, small values (theme, sidebar) | Session (default) | +| Per-user, survives across devices, bulky values (columns, filters) | Ecto adapter (you write it — see recipes below) | +| Pluggable per setting (e.g. theme in session, columns in DB) | Mix both, route by prefix | + +The Session adapter stores everything in a single Phoenix session key. If +your session is cookie-backed the whole tree must fit under ~4KB, so avoid +routing bulky per-resource state there. + +### Routing by prefix + +```elixir +# config/config.exs +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, []} +``` + +Dispatch uses **longest-prefix match**, so specific patterns always beat +`:default` regardless of order. Patterns: + +- `"global.*"` — any key whose first segment is `"global"`. +- `"global.theme"` — exact match, beats `"global.*"`. +- A 1-arity function `(String.t() -> boolean())` — escape hatch for + cross-cutting carve-outs (see below). +- `:default` — fallback when nothing else matches. + +With no `:adapters` config, the router falls back to a single `:default` → +Session route so existing apps need no changes. + +#### Match functions — cross-cutting carve-outs + +Trailing-wildcard patterns can only carve off a *prefix* of the key space. +When you need to route by a **suffix** (e.g. send every resource's column +visibility to the session while the rest of `resource.*` goes to a database), +use a 1-arity function as the pattern: + +```elixir +config :backpex, Backpex.Preferences, + adapters: [ + # Match funs are the most specific route type. Use them for cross-cutting + # carve-outs (e.g., every resource's column visibility regardless of module). + {&String.ends_with?(&1, ":columns"), Backpex.Preferences.Adapters.Session, []}, + {"resource.*", MyApp.Preferences.EctoAdapter, repo: MyApp.Repo}, + {:default, Backpex.Preferences.Adapters.Session, []} + ] +``` + +Semantics: + +- **Match functions are the most specific tier.** They always beat string + patterns and `:default`, regardless of how specific those look. Rationale: + the user wrote imperative matching code, so we assume they know what they + are doing. +- **First-in-config-order wins among multiple matching functions.** This + differs from the longest-prefix rule that applies to string patterns — make + sure ordering is deliberate when you have more than one function route. +- **Match functions are excluded from `get_map/3` (subtree reads).** A + function that picks off individual keys (every key ending in `:columns`) + cannot cleanly own the subtree rooted at `resource.SomeLive`. Only string + patterns and `:default` participate in subtree owner lookups. If you also + need the matched keys reachable through a subtree read, pair the function + route with a string pattern that owns the whole subtree. + +### Identity resolver + +Database adapters need a user id. Rather than each adapter implementing its +own lookup, configure **one** resolver and every adapter gets the result: + +```elixir +# config/config.exs +config :backpex, Backpex.Preferences, + identity: {MyAppWeb.PreferencesIdentity, :resolve, []} +``` + +```elixir +defmodule MyAppWeb.PreferencesIdentity do + alias Backpex.Preferences.Context + + # Prefer assigns: Backpex passes the live socket's / conn's assigns in + # `ctx.assigns`, so whatever your auth layer put there (current_scope, + # current_user, ...) is already resolved by the time preferences are read. + def resolve(%Context{assigns: %{current_scope: %{user: %{id: id}}}}), do: id + def resolve(%Context{assigns: %{current_user: %{id: id}}}), do: id + + # Fall back to the raw session only when assigns can't answer (e.g. a + # non-LiveView write path, a test that constructed a Context by hand). + def resolve(%Context{session: %{"user_token" => token}}) when is_binary(token) do + case MyApp.Accounts.get_user_by_session_token(token) do + %{id: id} -> id + _ -> :unidentified + end + end + + def resolve(_ctx), do: :unidentified +end +``` + +See the [Contracts](#contracts) section for why the assigns-first order +matters and what the host app must guarantee for it to hold. + +The dispatcher calls the resolver once per read/write call — there is no +cross-call memoization, so the resolver runs every time. Keep it cheap +(assigns lookup, session read, or a fast cache hit). The resolved value is +stashed on `ctx.identity` so each adapter call during that single dispatch +reuses the same value. Return `:unidentified` (or raise) when no user is +logged in. Adapter reads are treated as "not found" and the caller falls +back to the `:default` option; writes return `{:error, :unidentified}` and +the controller responds `200 {ok: false, errors: [{…, :unidentified}]}`. + +## Writing a custom adapter + +Implement `Backpex.Preferences.Adapter`. Three callbacks: + +- `get/3` — read one key. Return `{:ok, value}` or `{:ok, :not_found}`. +- `get_map/3` — read everything under a prefix as a nested map. +- `put/4` — persist one value; return a list of **side effects** for the + caller to apply (`[:noop]` if you already persisted; `[{:put_session, k, + map}]` when you need the caller to update the session). + +The side-effect protocol is what keeps adapters pure. They don't touch +`Plug.Conn` — they describe what the caller should do. This is what lets +the controller compose cross-adapter batch writes and lets server-side code +dispatch the same adapters without an HTTP request. + +Batch writes are **best-effort, first-error-wins**: on the first adapter +error the dispatcher halts, returns `{:error, {key, reason}}`, and the +controller responds `422 {ok: false, error: %{key: _, reason: _}}` without +applying any session-backed side effects collected earlier in the batch. +Adapters that persist eagerly (e.g. a DB-backed adapter that wrote via +`Repo.insert!`) may have already committed earlier writes — the adapter +behaviour has no rollback primitive, so callers should treat partial +success as possible. + +### In-memory test adapter + +Useful when exercising preferences in integration tests without spinning up +a database: + +```elixir +defmodule MyApp.Test.InMemoryPreferencesAdapter do + @behaviour Backpex.Preferences.Adapter + + @table :my_app_test_prefs + + def start do + case :ets.whereis(@table) do + :undefined -> :ets.new(@table, [:named_table, :public, :set]) + _ref -> :ok + end + end + + def reset, do: (start(); :ets.delete_all_objects(@table); :ok) + + @impl true + def get(ctx, key, _opts) do + start() + case :ets.lookup(@table, {identity(ctx), key}) do + [{_, value}] -> {:ok, value} + [] -> {:ok, :not_found} + end + end + + @impl true + def get_map(ctx, prefix, _opts) do + start() + # Reconstruct a nested map from flat (identity, key) rows — see + # lib/backpex/preferences/adapters/session.ex for the shape to return. + {:ok, %{}} + end + + @impl true + def put(ctx, key, value, _opts) do + start() + :ets.insert(@table, {{identity(ctx), key}, value}) + {:ok, [:noop]} + end + + defp identity(%{identity: nil}), do: :anonymous + defp identity(%{identity: :unidentified}), do: :anonymous + defp identity(%{identity: id}), do: id +end +``` + +Backpex itself uses exactly this pattern for its dispatcher tests — see +`test/support/in_memory_preferences_adapter.ex` for a fully-worked version. + +## Ecto adapter recipes + +Backpex ships the adapter behavior but not an Ecto adapter, because the +right table shape depends on how your app already organizes user data. Below +are two complete recipes — pick whichever matches your schema. + +### Recipe A — generic key/value table + +Good default when you don't already have a settings/profile table. Each row +is one preference. + +```elixir +defmodule MyApp.Repo.Migrations.CreateBackpexUserPreferences do + use Ecto.Migration + + def change do + create table(:backpex_user_preferences) do + add :user_id, references(:users, on_delete: :delete_all), null: false + add :key, :string, null: false + add :value, :map, null: false, default: %{} + timestamps(type: :utc_datetime_usec) + end + + create unique_index(:backpex_user_preferences, [:user_id, :key]) + end +end + +defmodule MyApp.Preferences.UserPreference do + use Ecto.Schema + import Ecto.Changeset + + schema "backpex_user_preferences" do + field :user_id, :integer + field :key, :string + field :value, :map, default: %{} + timestamps(type: :utc_datetime_usec) + end + + def changeset(user_preference, attrs) do + user_preference + |> cast(attrs, [:user_id, :key, :value]) + |> validate_required([:user_id, :key, :value]) + |> unique_constraint([:user_id, :key]) + end +end + +defmodule MyApp.Preferences.EctoAdapter do + @behaviour Backpex.Preferences.Adapter + + import Ecto.Query + alias MyApp.Preferences.UserPreference + + @impl true + def get(%{identity: :unidentified}, _key, _opts), do: {:ok, :not_found} + def get(%{identity: user_id}, key, opts) do + repo = Keyword.fetch!(opts, :repo) + + case repo.one(from p in UserPreference, where: p.user_id == ^user_id and p.key == ^key, select: p.value) do + nil -> {:ok, :not_found} + %{"__raw__" => value} -> {:ok, value} + value -> {:ok, value} + end + end + + @impl true + def get_map(%{identity: :unidentified}, _prefix, _opts), do: {:ok, %{}} + def get_map(%{identity: user_id}, prefix, opts) do + repo = Keyword.fetch!(opts, :repo) + like = prefix <> "%" + + rows = + repo.all( + from p in UserPreference, + where: p.user_id == ^user_id and like(p.key, ^like), + select: {p.key, p.value} + ) + + nested = reshape_to_nested(rows, prefix) + {:ok, nested} + end + + @impl true + def put(%{identity: :unidentified}, _key, _value, _opts), do: {:error, :unidentified} + def put(%{identity: user_id}, key, value, opts) do + repo = Keyword.fetch!(opts, :repo) + + attrs = %{user_id: user_id, key: key, value: wrap_value(value)} + + %UserPreference{} + |> UserPreference.changeset(attrs) + |> repo.insert!(on_conflict: {:replace, [:value, :updated_at]}, conflict_target: [:user_id, :key]) + + {:ok, [:noop]} + end + + defp wrap_value(map) when is_map(map), do: map + defp wrap_value(other), do: %{"__raw__" => other} + + defp reshape_to_nested(rows, prefix) do + prefix_segments = Backpex.Preferences.Key.parse(prefix) + + Enum.reduce(rows, %{}, fn {key, value}, acc -> + value = case value do + %{"__raw__" => v} -> v + v -> v + end + + segments = Backpex.Preferences.Key.parse(key) + case Enum.split(segments, length(prefix_segments)) do + {^prefix_segments, []} -> acc + {^prefix_segments, rest} -> put_path(acc, rest, value) + _ -> acc + end + end) + end + + defp put_path(map, [k], value), do: Map.put(map, k, value) + + defp put_path(map, [k | rest], value) do + child = Map.get(map, k) + child = if is_map(child), do: child, else: %{} + Map.put(map, k, put_path(child, rest, value)) + end +end + +# config/config.exs +config :backpex, Backpex.Preferences, + adapters: [ + {"resource.*", MyApp.Preferences.EctoAdapter, repo: MyApp.Repo}, + {:default, Backpex.Preferences.Adapters.Session, []} + ] +``` + +### Recipe B — prefix → column mapping + +Use when you already have a user settings table (one row per user) with +typed JSON columns. Lets each Backpex prefix write into a named column +rather than a generic rows table. + +When you already have a typed settings table, adapt Recipe A by replacing +the k/v schema: route each prefix to its own column and dispatch reads and +writes based on the key's segments. See the +[ash_backpex](https://github.com/enoonan/ash_backpex) community example for +a working implementation. + +## Opt-in persistence for ordering, filters, columns + +By default `Backpex.LiveResource` keeps ordering and filters in the URL and +column visibility in-memory. Opt in per resource to persist any subset via +`Backpex.Preferences`: + +```elixir +use Backpex.LiveResource, + adapter_config: [...], + persist: [:order, :filters, :columns] +``` + +What each flag does: + +- **`:order`** — reads `resource::order` at mount; uses it as the + initial order when the URL has no `order_by` / `order_direction` params. + Writes every time the order changes. +- **`:filters`** — reads `resource::filters` at mount; uses it as + the fallback filter set when the URL has no `filters` param. Writes every + time filters change. +- **`:columns`** — reads `resource::columns` at mount; writes on + `toggle_column` events. Default without opt-in is to keep column state + in-memory only. + +All three keys route through whichever adapter you configured for +`"resource.*"` — typically the Session adapter by default, or a per-user DB +adapter once you wire one up. + +### Replacing a hand-rolled persistence layer + +If your app already persists ordering, filters, or column state through a +custom `init_order` callback backed by a DB table, the `persist:` option +replaces that scaffolding: + +```elixir +# Hand-rolled +use Backpex.LiveResource, adapter_config: [...] + +def init_order(assigns), do: MyApp.OrderingSettings.fetch(assigns.current_user, __MODULE__) + +def handle_event(...) do + # ... hand-rolled write to MyApp.OrderingSettings ... +end +``` + +```elixir +# With persist: +use Backpex.LiveResource, + adapter_config: [...], + persist: [:order] +# MyApp.OrderingSettings writes move into MyApp.Preferences.EctoAdapter once; +# every opt-in resource benefits. +``` + +## Custom preferences + +The system is a flat key-value store with a namespace convention. Use +`custom.*` for your own keys — the router won't collide with anything Backpex +ships. + +### Reading (server-side) + +```elixir +def mount(_params, session, socket) do + view_mode = Backpex.Preferences.get(session, "custom.dashboard.view_mode", default: "grid") + panel_states = Backpex.Preferences.get_map(session, "custom.dashboard.panels") + + {:ok, assign(socket, view_mode: view_mode, panel_states: panel_states)} +end +``` + +### Writing from the browser + +```javascript +import { BackpexPreferences } from 'backpex' + +BackpexPreferences.set('custom.dashboard.view_mode', 'list') +``` + +### Writing from the server + +From a LiveView `handle_event`, use `Backpex.Preferences.put/4`: + +```elixir +def handle_event("toggle_view_mode", _params, socket) do + new_mode = if socket.assigns.view_mode == "grid", do: "list", else: "grid" + + {:ok, socket} = Backpex.Preferences.put(socket, "custom.dashboard.view_mode", new_mode) + + {:noreply, assign(socket, :view_mode, new_mode)} +end +``` + +Under the hood `put/4` tries the configured adapter first. When the +adapter is session-backed (no HTTP request in a LiveView event), it falls +back to a `push_event/3` round-trip so the browser persists via the +preferences controller on its next paint. DB-backed adapters just write +directly and return. + +### Validating custom keys + +Preference keys are strings, and nothing stops a hand-written `"custm.foo"` +from compiling and silently returning the default. Backpex ships an +opt-in validator to catch typos mechanically. + +**The `custom.*` convention.** Keep every app-owned key under the +`custom.*` prefix (`custom.dashboard.view_mode`, `custom.density.compact`, +…). The validator accepts `global.*`, `resource.*`, and `custom.*` out of +the box — anything else is flagged. + +**Registering an extra prefix.** Rare, but occasionally an app has a +genuine reason to want its own top-level prefix (e.g. a distinct +`experimental.*` namespace during a rollout). Configure it: + +```elixir +# config/config.exs +config :backpex, Backpex.Preferences.Key, + extra_prefixes: ["experimental"] +``` + +Prefer `custom..` first — an extra prefix is extra +configuration every future contributor has to know about. + +**Turning the validator on.** Default is off so production logs stay +quiet. Turn it on where typos are a problem: + +```elixir +# config/dev.exs — warn about typos while developing +config :backpex, Backpex.Preferences, validate_keys: :log + +# config/test.exs — fail the test suite if anything emits an unknown key +config :backpex, Backpex.Preferences, validate_keys: true +``` + +`:log` emits `Logger.warning/1` on each unknown key and still dispatches +the call (returns the default on reads). `true` raises `ArgumentError` on +the spot, surfacing typos as loud test failures. The validator runs on +`get/3`, `fetch/3`, `get_map/3`, and `put/4`. `put_batch/3` is intentionally +skipped — it is the cross-adapter dispatch used by +`Backpex.PreferencesController` and should not reject requests for an +unknown prefix at the library boundary. + +`Backpex.Preferences.Keys` supplies helpers (`theme/0`, `sidebar_open/0`, +`columns/1`, …) that are self-checked at compile time to always emit +valid keys. Prefer those over inline strings whenever a built-in key +exists — and let the validator catch the remaining hand-written +`custom.*` strings. + +## Gotchas + +### Preserving preferences across session renewal + +The Phoenix-generated `UserAuth.renew_session/2` pattern clears the session +on login/logout and re-puts an allowlist of keys. Unless you know the +specific session key Backpex uses, it will silently drop on renewal — users +lose their theme, sidebar state, and persisted filters/order/columns every +time they sign in or out. + +Use `Backpex.Preferences.Session.preserve/1` and `restore/2` to include +Backpex preferences in the renewal without hardcoding the session key: + +```elixir +def renew_session(conn, _user) do + backpex_prefs = Backpex.Preferences.Session.preserve(conn) + + conn + |> configure_session(renew: true) + |> clear_session() + |> then(&Backpex.Preferences.Session.restore(&1, backpex_prefs)) +end +``` + +Both calls are no-ops when no preferences are stored, so they are safe to +add unconditionally. + +### Default vs. explicit empty + +When deciding whether to apply a default, use `Backpex.Preferences.fetch/3` +and pattern-match on `:error` vs `{:ok, value}`. Don't treat a resolved +`%{}` or `[]` as "never set" — a user who explicitly cleared their filters +(or their columns, or any other map/list preference) stored that empty +value deliberately. Overwriting it with a default on the next mount means +every page load fights the user's choice. + +```elixir +# Wrong — treats "user cleared filters" the same as "no preference". +filters = Backpex.Preferences.get(session, key, default: %{}) +if filters == %{}, do: apply_defaults(), else: use(filters) + +# Right — distinguishes the two. +case Backpex.Preferences.fetch(session, key) do + {:ok, filters} -> use(filters) # includes an explicit %{} + :error -> apply_defaults() # user has never set this + {:error, _} -> apply_defaults() # adapter failure, already logged +end +``` + +`Backpex.LiveResource`'s own `persist: [:filters]` wiring follows this +rule; apply the same pattern in any custom persistence logic you build on +top of `Backpex.Preferences`. +## Cross-tab sync (PubSub) + +Preference writes are fire-and-forget: the browser POSTs, the adapter +persists, and the rest of the app learns about the change on the next fresh +mount. That is fine for a single tab. It is not fine for two tabs of the +same admin — toggling theme in one leaves the other on the stale value +until the user reloads it. + +Backpex can broadcast every successful write on a Phoenix PubSub so other +LiveViews (other tabs, other connections for the same user) can react in +real time. The feature is **opt-in** and costs nothing when unconfigured. + +### Enable it + +```elixir +# config/config.exs +config :backpex, Backpex.Preferences, + adapters: [...], + identity: {MyAppWeb.PreferencesIdentity, :resolve, []}, + pubsub: [server: MyApp.PubSub, topic_prefix: "backpex_preferences"] +``` + +- `:server` — the name of your Phoenix PubSub process. Usually the one + your endpoint already starts. +- `:topic_prefix` — optional. Defaults to `"backpex_preferences"`. Change + it only if it collides with something your app already broadcasts on. + +When `:pubsub` is absent, writes do not broadcast at all. No PubSub +lookup, no try/rescue — zero cost. + +### Message shape + +After every successful `put/4` **and** every successful entry in +`put_batch/3`, Backpex broadcasts on `":"`: + +```elixir +{:backpex_preference_changed, + %{key: "global.theme", value: "dark", source: :controller}} +``` + +- `:source` is `:controller` for HTTP-controller writes (the hot path — + the JS hook POSTing to the preferences endpoint) and `:server` for + server-side `Preferences.put/4` calls from a LiveView that bypassed + the browser. +- Failed writes do not broadcast. Neither does the `:requires_http` + fallback path: the socket hands the write to the browser and the + browser's POST will produce the broadcast with `:source == :controller`. +- Broadcast failures (misconfigured server name, etc.) are logged and + swallowed — a bad broadcast NEVER breaks a preference write. + +### Subscribe from a LiveView + +```elixir +def mount(_params, _session, socket) do + if connected?(socket) do + Backpex.Preferences.subscribe(socket.assigns.current_user.id) + end + + {:ok, socket} +end + +def handle_info({:backpex_preference_changed, %{key: "global.theme", value: theme}}, socket) do + # Re-render with the new theme. Your handler decides how much to reconcile; + # a simple `assign(:current_theme, theme)` is often enough. + {:noreply, assign(socket, :current_theme, theme)} +end + +def handle_info({:backpex_preference_changed, _other}, socket) do + # Ignore keys this view does not care about. + {:noreply, socket} +end +``` + +`Backpex.Preferences.subscribe/1` takes an identity — whatever your +identity resolver returns. Use the same value here that the resolver would +resolve to for the current user; otherwise your subscription will miss +broadcasts targeted at a different topic. `unsubscribe/1` mirrors it. + +### Why per-identity topics + +Broadcasting globally would fan every user's write out to every other +user's session — a privacy leak dressed up as a feature. Backpex keys the +topic off the resolved identity so each user's tabs are the only ones +listening. Writes made with no resolved identity (`:unidentified` from the +resolver, or no resolver configured) land on `":anonymous"` +so they stay debuggable but don't cross-pollinate. Consumer code that +subscribes for real users should ignore the anonymous topic. + +### Gotcha: `:unidentified` writes + +If your identity resolver returns `:unidentified` for a write (anonymous +visitor, background job, test), the broadcast lands on the anonymous topic. +This is intentional — it keeps the write path simple and the broadcasts +inspectable during development — but it means a `subscribe(user_id)` +call will not receive `:unidentified` writes for that user. Make sure your +resolver consistently returns the same identity on both the read and write +paths. + +## Testing Backpex LiveResources + +When a resource has [filter presets](../filter/filter-presets.md) (a +`:default` on a filter config) and the user has no persisted filter state +yet, the Index view issues a `push_navigate` on first mount to apply those +defaults. Under `Phoenix.LiveViewTest.live/2` that surfaces as an +`{:error, {:live_redirect, _}}` tuple that your test has to match on and +re-mount — every integrator hits this footgun the first time they write an +Index-mount test. + +`Backpex.Test` ships helpers that absorb that boilerplate. Import it in +your ExUnit cases (no extra dependency; the module is part of the Backpex +package): + +```elixir +import Backpex.Test +``` + +### `live_resource_index/3` + +Mounts a LiveResource Index view and transparently follows the default-filter +redirect if one is issued: + +```elixir +test "index renders with the preset filters applied", %{conn: conn} do + {:ok, _view, html} = live_resource_index(conn, MyAppWeb.PostLive) + assert html =~ "Published" +end +``` + +Pass the **top-level** LiveResource module (`MyAppWeb.PostLive`), not the +generated `*.Index` sub-module. The macro uses the conn's router (which +Phoenix populates after the first dispatch in a `ConnCase` test) to derive +the Index URL. + +Options: + +- `:url` — override the mount URL entirely. Use this when the resource is + mounted under multiple paths, or when you want to pass exactly the query + string you care about. +- `:query` — a map or keyword list merged into the derived URL's query + string. Ignored when `:url` is set. + +A redirect to a **different** path bubbles up as the original `{:error, ...}` +tuple rather than being silently followed — one hop only. + +### `put_preference/3` + +Seeds a preference into the conn's session before mount, without going +through the HTTP preferences controller. Handy for pinning +persisted-state-on-mount branches: + +```elixir +test "persisted empty filters suppress the default-filter redirect", %{conn: conn} do + conn = + conn + |> put_preference(Backpex.Preferences.Keys.filters(MyAppWeb.PostLive), %{}) + + {:ok, _view, html} = live_resource_index(conn, MyAppWeb.PostLive) + + # No redirect — the explicit empty filter state beat the `:default`. + assert html =~ "Draft" + assert html =~ "Published" +end +``` + +`put_preference/3` dispatches through the configured adapter the same way +any production write does — so tests that swap in a DB-backed adapter also +see their seeds persisted there, not only in the session. + +## Writing a JS hook that persists preferences + +### Why sessionStorage mirroring exists + +LiveView freezes the Phoenix session at websocket-connect time. The +`BackpexPreferences.set/2` HTTP POST writes immediately to the cookie, but +an in-flight LiveView socket holds the older session snapshot until the +next fresh connect. When a user clicks an internal link that does a +`live_redirect`, LiveView re-mounts the target view **on the same socket** +— so `on_mount` callbacks (including `Backpex.InitAssigns`) read the stale +snapshot and the server re-renders UI chrome from the pre-write value. +The user sees a momentary reversion of their own toggle. + +To survive that re-mount the JS hook needs a client-authoritative value +that bypasses the server entirely. `sessionStorage` is the natural fit: +same tab, cleared on tab close, no cookie-size pressure. `BackpexPreferences` +provides `get(key, fallback)` and `set(key, value, { mirror: 'session' })` +so every hook gets the same, namespaced (`backpex.prefs.*`) behavior +without reinventing load/save helpers. + +### When to use `mirror: 'session'` + +Use it when **all** of the following are true: + +- The preference controls UI chrome re-rendered by the LiveView on every + mount (sidebar, density, nav variant, table zoom, …). +- The server reads this preference from the session at mount time (so the + stale snapshot will bite you on `live_redirect`). +- The client-side value can diverge from the server's view between a + write and the next fresh websocket handshake. + +### When NOT to use it + +Skip the mirror (just call `BackpexPreferences.set(key, value)`) when: + +- The server is always authoritative on every render — e.g. a DB-backed + preference read fresh from Ecto in `mount/3`. There's no stale snapshot + to override. +- The visible UI state lives outside the LiveView-rendered tree. The + built-in theme selector is an example: `data-theme` on `` is set + once by JS and never re-rendered by the server, so a stale session read + only mislabels the (hidden-by-default) selector radio and isn't worth + the overhead. +- You need cross-tab consistency within the browser — `sessionStorage` is + per-tab; a mirror there will diverge between two tabs of the same admin. + +### Example: a compact-density toggle + +```javascript +// assets/js/hooks/compact_density_toggle.js +import { BackpexPreferences } from 'backpex' + +export default { + mounted () { + // Seed from the mirror first (live_redirect-safe), falling back to + // the server-rendered data attribute on fresh connects. + const compact = BackpexPreferences.get( + 'custom.density.compact', + this.el.dataset.compact === 'true' + ) + this.applyDensity(compact) + + this.el.addEventListener('click', () => { + const next = !this.el.classList.contains('density-compact') + this.applyDensity(next) + // Writes sessionStorage first (instant, survives live_redirect), + // then POSTs to the preferences endpoint for the next fresh connect. + BackpexPreferences.set('custom.density.compact', next, { mirror: 'session' }) + }) + }, + + applyDensity (compact) { + this.el.classList.toggle('density-compact', compact) + this.el.setAttribute('aria-pressed', String(compact)) + } +} +``` + +`get/2` uses the runtime type of the fallback to deserialize: a boolean +fallback returns `true`/`false`; a number returns a parsed number; a string +passes through; anything else (map, array) round-trips through JSON. Pick +a fallback whose type matches what you `set/3`-ed originally and the +round-trip stays transparent. + +## Troubleshooting + +**"My preferences vanish after a few writes."** The default cookie-backed +session has a hard ~4KB limit; once the tree overflows, the session silently +truncates. Route bulky prefixes (columns, filters) onto a database adapter. + +**"Changes aren't saving for some users."** The configured adapter likely +returned `{:error, :unidentified}` — your identity resolver couldn't find a +user (e.g. auth plug hasn't run yet). Check `Plug.Conn.get_session(conn, +:user_id)` / `socket.assigns.current_user` at the moment the write is made. + +**"I want to inspect what's stored for a user."** Session adapter: read +`Plug.Conn.get_session(conn, "backpex_preferences")` directly. DB adapter: +query your table (`backpex_user_preferences` or whatever you named it). + +**"How do I reset a user's preferences?"** Drop their rows (DB adapter) or +`Plug.Conn.delete_session(conn, "backpex_preferences")` (session). No +Backpex-specific API exists — treat the store like the data store it is. diff --git a/guides/upgrading/v0.19.md b/guides/upgrading/v0.19.md index c67ef6aa4..f51a36100 100644 --- a/guides/upgrading/v0.19.md +++ b/guides/upgrading/v0.19.md @@ -90,3 +90,197 @@ The sidebar now switches between the mobile drawer and the inline desktop layout ### Translations Some translation strings have been renamed for the new sidebar. See the [`backpex.pot` file for v0.19.0](https://github.com/naymspace/backpex/blob/0.19.0/priv/gettext/backpex.pot) in our GitHub repository for the current set of msgids to match on. + +## User Preferences System Overhaul + +> #### Warning {: .warning} +> +> 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). + +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. + +Backpex ships with a Phoenix-session adapter out of the box, so no action is needed to keep preferences in the session. 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. + +### Preferences: adapter architecture + +- **Terminology.** Storage is pluggable (session, ETS, Ecto, ...), so the + subsystem is called "preferences" rather than "cookie-based preferences." + The JS property exposed to hooks is `BackpexPreferences.endpointPath`. +- **Default behavior.** With no config, every key routes to + `Backpex.Preferences.Adapters.Session`, so existing apps need no changes + to keep working. +- **Route a prefix to your database.** Implement + `Backpex.Preferences.Adapter` and add the route: + + ```elixir + config :backpex, Backpex.Preferences, + adapters: [ + {"resource.*", MyApp.Preferences.EctoAdapter, repo: MyApp.Repo}, + {:default, Backpex.Preferences.Adapters.Session, []} + ], + identity: {MyAppWeb.PreferencesIdentity, :resolve, []} + ``` + + Full walkthrough (migration + schema + adapter module, two table shapes) + in the [User Preferences guide](../live_resource/user-preferences.md). +- **Per-resource module keys.** Internal + `resource::` keys use `:` as a separator so module-name + dots don't create accidental path segments. +- **New `persist:` option on `use Backpex.LiveResource`.** Opt in to + persisting ordering, filters, or column visibility through the + preferences layer: + + ```elixir + use Backpex.LiveResource, + adapter_config: [...], + persist: [:order, :filters, :columns] + ``` + + Not required, not a breaking change — default is `[]`, which keeps + ordering and filters in the URL and column visibility in-memory. + +### Breaking Changes + +#### 1. Remove `Backpex.ThemeSelectorPlug` + +The `Backpex.ThemeSelectorPlug` has been removed. Theme preferences are now handled by `Backpex.InitAssigns`. + +**Before:** +```elixir +# router.ex +pipeline :browser do + ... + plug Backpex.ThemeSelectorPlug +end +``` + +**After:** +```elixir +# router.ex +pipeline :browser do + ... + # Remove the ThemeSelectorPlug line - it's no longer needed +end +``` + +#### 2. Update root layout theme attribute + +Change the theme assign name from `@theme` to `@current_theme`: + +**Before:** +```heex + +``` + +**After:** +```heex + +``` + +#### 3. Update theme_selector component + +The `theme_selector` component now requires `current_theme` instead of relying on localStorage: + +**Before:** +```heex + +``` + +**After:** +```heex + +``` + +#### 4. Update app_shell component + +The `app_shell` component now requires `sidebar_open` attribute: + +**Before:** +```heex + +``` + +**After:** +```heex + +``` + +#### 5. Update sidebar_section component + +The `sidebar_section` component now requires `sidebar_section_states` instead of a manual `open` attribute: + +**Before:** +```heex + + <:label>Blog + ... + +``` + +**After:** +```heex + + <:label>Blog + ... + +``` + +#### 6. Remove setStoredTheme call + +If you were calling `BackpexHooks.BackpexThemeSelector.setStoredTheme()` in your `app.js`, you can remove it. The server now renders the correct theme on initial page load, so this workaround is no longer needed. + +**Before:** +```javascript +import { Hooks as BackpexHooks } from 'backpex'; +// ... +BackpexHooks.BackpexThemeSelector.setStoredTheme() +``` + +**After:** +```javascript +import { Hooks as BackpexHooks } from 'backpex'; +// Remove the setStoredTheme() call +``` + +#### 7. `Backpex.Router.cookie_path/1` renamed to `preferences_path/1` + +`Backpex.Router.cookie_path/1` has been renamed to `Backpex.Router.preferences_path/1`. The HTTP route generated by `backpex_routes/0` was also renamed from `/backpex_cookies` to `/backpex_preferences`. The `backpex_routes/0` macro itself is unchanged, so most users do not need to do anything; only forks of the topbar or theme selector that call the helper directly (or that hard-coded the path) need an update. + +**Before:** +```heex + +``` + +**After:** +```heex + +``` + +### New Assigns from Backpex.InitAssigns + +The `Backpex.InitAssigns` hook now provides these additional assigns: + +| Assign | Type | Description | +|--------|------|-------------| +| `@current_theme` | string | The user's selected theme | +| `@sidebar_open` | boolean | Whether the sidebar is open (desktop) | +| `@sidebar_section_states` | map | Map of section IDs to open/closed state | + +These assigns are populated from session cookies and can be used directly in your layout templates. + +### Benefits + +- **No more flickering**: The server renders the correct initial state based on saved preferences +- **Consistent behavior**: All preferences use the same storage mechanism +- **Simplified setup**: No need for separate plugs or localStorage workarounds diff --git a/lib/backpex/controllers/cookie_controller.ex b/lib/backpex/controllers/cookie_controller.ex deleted file mode 100644 index a2823d7ba..000000000 --- a/lib/backpex/controllers/cookie_controller.ex +++ /dev/null @@ -1,89 +0,0 @@ -defmodule Backpex.CookieController do - use Phoenix.Controller, formats: [:html, :json] - - import Plug.Conn - - @form_url_key "_cookie_redirect_url" - @form_resource_key "_resource" - - @backpex_key "backpex" - @toggle_columns_key "column_toggle" - @toggle_metrics_key "metric_visibility" - @select_theme_key "theme" - - def update(conn, %{"toggle_columns" => form_data}) do - resource = Map.get(form_data, @form_resource_key) - - to = redirect_url(form_data) - fields = Map.drop(form_data, [@form_url_key, @form_resource_key]) - - value = Map.put(%{}, resource, fields) - - conn - |> put_backpex_session(@toggle_columns_key, value) - |> redirect(to: to) - end - - def update(conn, %{"toggle_metrics" => form_data}) do - resource = Map.get(form_data, @form_resource_key) - - to = redirect_url(form_data) - - is_visible = - conn - |> get_session(@backpex_key) - |> get_in([@toggle_metrics_key, resource]) - |> then(fn - nil -> - true - - value -> - value - end) - - value = Map.put(%{}, resource, !is_visible) - - conn - |> put_backpex_session(@toggle_metrics_key, value) - |> redirect(to: to) - end - - def update(conn, %{"select_theme" => theme_name}) do - backpex_session = get_session(conn, @backpex_key) || %{} - value = Map.put(backpex_session, @select_theme_key, theme_name) - - conn - |> put_session(@backpex_key, value) - |> json(%{}) - end - - defp redirect_path(%URI{path: path, query: nil}) do - URI.new!(path) - end - - defp redirect_path(%URI{path: path, query: query}) do - URI.new!(path) - |> Map.put(:query, query) - end - - defp redirect_url(form_data) do - form_data - |> Map.get(@form_url_key) - |> URI.parse() - |> redirect_path() - |> URI.to_string() - end - - defp put_backpex_session(conn, key, value) do - backpex_session = get_session(conn, @backpex_key) || %{} - - merged_value = - backpex_session - |> Map.get(key, %{}) - |> Map.merge(value) - - value = Map.put(backpex_session, key, merged_value) - - put_session(conn, @backpex_key, value) - end -end diff --git a/lib/backpex/controllers/preferences_controller.ex b/lib/backpex/controllers/preferences_controller.ex new file mode 100644 index 000000000..df9cabfe5 --- /dev/null +++ b/lib/backpex/controllers/preferences_controller.ex @@ -0,0 +1,95 @@ +defmodule Backpex.PreferencesController do + @moduledoc """ + HTTP endpoint for persisting user preferences. + + Accepts JSON requests from the `BackpexPreferences` JS hook. Each call + routes through `Backpex.Preferences`, which dispatches to the adapter + configured for the key's prefix (see `Backpex.Preferences.Router`). + + ## Contracts + + Single write: + + POST /backpex_preferences + {"key": "global.theme", "value": "dark"} + + Batch write: + + POST /backpex_preferences + {"preferences": [ + {"key": "global.theme", "value": "dark"}, + {"key": "global.sidebar_open", "value": true} + ]} + + The batch form is **best-effort, first-error-wins**: if any adapter refuses + a write, the dispatcher halts at that entry, no further adapters are + called, and the response is `422 {ok: false, error: %{key: _, reason: _}}`. + Session-backed effects from earlier successful entries in the same batch + are also dropped (the controller never applies them on the error path), so + the session cookie is left unchanged. However, adapters that persist + eagerly (e.g. a DB-backed adapter that wrote via `Repo.insert!`) may have + already committed earlier writes — the adapter behaviour has no rollback + primitive, so callers should treat partial success as possible. + + Single-write `:unidentified` is treated as a no-op rather than an error: + the response is `200 {ok: false, error: %{reason: "unidentified"}}` and no + warning is logged. The JS hook fires writes from anonymous visitors + whenever the session lapses — this avoids surfacing them as 4xx noise. + Batches always halt on any error (including `:unidentified`) and return + 422. + """ + + use Phoenix.Controller, formats: [:json] + + alias Backpex.Preferences + alias Backpex.Preferences.Context + + require Logger + + @doc false + def update(conn, %{"key" => key, "value" => value}) do + update(conn, %{"preferences" => [%{"key" => key, "value" => value}]}) + end + + def update(conn, %{"preferences" => list}) when is_list(list) do + entries = + list + |> Enum.filter(&match?(%{"key" => k, "value" => _value} when is_binary(k), &1)) + |> Enum.map(fn %{"key" => k, "value" => v} -> {k, v} end) + + ctx = Context.from_conn(conn) + + case Preferences.put_batch(ctx, entries) do + {:ok, effects} -> + conn + |> Preferences.apply_effects_on_conn(effects) + |> json(%{ok: true}) + + {:error, {key, :unidentified}} when length(entries) == 1 -> + # Anonymous visitors hitting a non-session adapter is an expected + # no-op, not a 4xx. The JS hook fires-and-forgets, so surfacing this + # as an error would only pollute Logger without affecting clients. + json(conn, %{ok: false, error: format_error({key, :unidentified})}) + + {:error, {key, reason}} -> + Logger.warning( + "[Backpex.PreferencesController] preference batch refused at key " <> + inspect(key) <> ": " <> inspect(reason) + ) + + conn + |> put_status(422) + |> json(%{ok: false, error: format_error({key, reason})}) + end + end + + def update(conn, _invalid_params) do + conn + |> put_status(400) + |> json(%{ok: false, error: "missing key/value"}) + end + + defp format_error({key, reason}), do: %{key: key, reason: format_reason(reason)} + defp format_reason(reason) when is_atom(reason), do: Atom.to_string(reason) + defp format_reason(reason), do: inspect(reason) +end diff --git a/lib/backpex/filters/range.ex b/lib/backpex/filters/range.ex index d622ecd44..1028c349d 100644 --- a/lib/backpex/filters/range.ex +++ b/lib/backpex/filters/range.ex @@ -304,11 +304,18 @@ defmodule Backpex.Filters.Range do def maybe_parse(type, value, is_end? \\ false) def maybe_parse(_type, "", _is_end?), do: nil + def maybe_parse(_type, nil, _is_end?), do: nil + + def maybe_parse(:date, %Date{} = value, _is_end?), do: Date.to_iso8601(value) def maybe_parse(:date, value, _is_end?) do if date?(value), do: value end + def maybe_parse(:datetime, %Date{} = value, false = _is_end?), do: Date.to_iso8601(value) <> "T00:00:00+00:00" + + def maybe_parse(:datetime, %Date{} = value, _is_end?), do: Date.to_iso8601(value) <> "T23:59:59+00:00" + def maybe_parse(:datetime, value, false = _is_end?) do if date?(value), do: value <> "T00:00:00+00:00" end @@ -357,7 +364,10 @@ defmodule Backpex.Filters.Range do nil """ - def parse_float_or_int(value) do + def parse_float_or_int(value) when is_integer(value), do: value + def parse_float_or_int(value) when is_float(value), do: value + + def parse_float_or_int(value) when is_binary(value) do case {Integer.parse(value), Float.parse(value)} do {{value, ""}, _parsed_float} -> value {_parsed_integer, {value, ""}} -> value @@ -365,6 +375,8 @@ defmodule Backpex.Filters.Range do end end + def parse_float_or_int(_other), do: nil + @doc """ Checks if a string is a valid ISO 8601 date. @@ -392,13 +404,15 @@ defmodule Backpex.Filters.Range do false """ - def date?(date) do + def date?(date) when is_binary(date) do case Date.from_iso8601(date) do {:ok, _date} -> true _err -> false end end + def date?(_other), do: false + @doc """ Returns the render type for form inputs. diff --git a/lib/backpex/html/layout.ex b/lib/backpex/html/layout.ex index 1a26d496d..9e502888c 100644 --- a/lib/backpex/html/layout.ex +++ b/lib/backpex/html/layout.ex @@ -27,9 +27,11 @@ defmodule Backpex.HTML.Layout do """ @doc type: :component + attr :socket, :any, required: true, doc: "the socket" attr :live_resource, :atom, default: nil, doc: "live resource module" attr :class, :string, default: nil, doc: "class added to the app shell container" attr :fluid, :boolean, default: false, doc: "toggles fluid layout" + attr :sidebar_open, :boolean, default: true, doc: "initial sidebar open state" slot :inner_block @@ -49,7 +51,15 @@ defmodule Backpex.HTML.Layout do id="backpex-app-shell" class={["min-h-screen", @class]} phx-hook={@sidebar != [] && "BackpexSidebar"} + data-sidebar-open={to_string(@sidebar_open)} > + <%!-- Sidebar (single element for both mobile and desktop) --%>