Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
c9c5488
Add user preference system for UI states
Flo0807 Jan 8, 2026
3f1d6b8
Fix tests
Flo0807 Jan 8, 2026
c4852ee
Merge branch 'feature/collapsible-sidebar' into feature/user-preferen…
Flo0807 Apr 17, 2026
df0fd36
Route preferences through a pluggable adapter architecture
Flo0807 Apr 17, 2026
94f203f
Rewrite user preferences guide for adapter architecture
Flo0807 Apr 17, 2026
cd78ea9
Drop module-ref backticks for Backpex.LiveResource.Index in guide
Flo0807 Apr 17, 2026
19a669e
Rebuild static JS assets for endpointPath rename
Flo0807 Apr 17, 2026
67bf334
Refactor user preferences
Flo0807 Apr 21, 2026
8f71c26
Route filter persistence through intent-driven handlers
Flo0807 Apr 21, 2026
f0c6fe1
Disambiguate clear-filter selector in preferences persistence test
Flo0807 Apr 21, 2026
a0ae584
Preserve sidebar section state across live_redirect
Flo0807 Apr 21, 2026
08eecbe
Preserve sidebar open state across live_redirect
Flo0807 Apr 21, 2026
4e7376a
Reword sidebar test comments to reflect WIP status
Flo0807 Apr 21, 2026
3d84348
Respect persisted empty filters over :default filters
Flo0807 Apr 21, 2026
9608b56
Thread socket.assigns through InitAssigns reads via Context
Flo0807 Apr 21, 2026
e7ba94d
Add session-preservation helper, document fetch/3, tighten default-vs…
Flo0807 Apr 21, 2026
f1d27f9
Add match_fun route pattern to Backpex.Preferences.Router
Flo0807 Apr 21, 2026
19040f6
Lift sessionStorage mirroring into BackpexPreferences.get/set
Flo0807 Apr 21, 2026
a30c30e
Collapse reject+filter into single filter in best_prefix_match
Flo0807 Apr 21, 2026
9694aa5
Rebuild static JS assets for sessionStorage mirror helpers
Flo0807 Apr 21, 2026
0b0e4d8
Validate preference keys with opt-in dispatcher guard
Flo0807 Apr 21, 2026
d2fda97
Broadcast preference changes on Phoenix.PubSub (opt-in)
Flo0807 Apr 21, 2026
6c808ae
Add Backpex.Test helpers for LiveResource tests
Flo0807 Apr 21, 2026
b6d0a05
Drop module-ref backticks to hidden Backpex.LiveResource.Index
Flo0807 Apr 21, 2026
af2e10a
Delete docs directory
Flo0807 Apr 22, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions assets/js/backpex.js
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import * as Hooks from './hooks'

export { Hooks }
export { BackpexPreferences } from './hooks/_preferences'
175 changes: 175 additions & 0 deletions assets/js/hooks/_preferences.js
Original file line number Diff line number Diff line change
@@ -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 }
111 changes: 83 additions & 28 deletions assets/js/hooks/_sidebar.js
Original file line number Diff line number Diff line change
@@ -1,11 +1,25 @@
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.
*
* Desktop: sidebar visible by default, content shifts when closed
* 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"])',

Expand All @@ -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.
Expand Down Expand Up @@ -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 () {
Expand All @@ -91,7 +122,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
Expand All @@ -100,16 +133,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
Expand Down Expand Up @@ -210,27 +233,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)
Expand All @@ -239,6 +265,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) {
Expand All @@ -263,6 +306,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' }
)
}
}
Loading