feat(admin): overhaul the users filter bar and list (BUNYIP-410 follow-up) #408

Merged
longjacksonle merged 1 commit from feat/BUNYIP-410-users-filter-overhaul into main 2026-07-30 00:01:23 +02:00

Rebuilds /admin/users from four stacked full-width controls into a real filter bar + sortable list. Before/after: the old page (ref_user.png) had ambiguous full-width selects showing "Verified"/"Early Adopter" as if chosen, a dead "Filter" button, and unstyled Active/Suspended buttons.

Filter bar

  • One wrapping row: search grows to fill; Verification and Tier are the app's own <details> dropdowns (not native selects) at content width, each with a persistent prefix ("Verification: Any", "Tier: Any") and an explicit Any default shown on first load, so a filtered state is never ambiguous.
  • Active / All / Suspended is a single segmented control (radiogroup, one selected, arrow-key navigable).
  • No "Filter" button: filters apply on change; search is debounced 250ms.

State + feedback

  • All filter/sort/page state is in the URL (linkable, refresh, back).
  • Applied filters render as removable chips with Clear all (only when non-default).
  • Heading reflects the segment; count shows "N of M users" via an aria-live region. Chips + count in reserved-height rows (no shift).

List

  • Grid with column headers; Email / Tier / Verification / Joined are sortable (server-side, whitelisted, direction toggles, indicator on the active column, persisted in the URL).
  • Letter-initial avatar per row, absolute-date title on the relative "Joined", whole-row link with hover bg + focus-visible ring + chevron.
  • Suspended rows (surfaced on the new "All" segment via a new suspended flag) are not links and keep an inline Reactivate.
  • Prev/next pager + page-size control (10/20/50/100).

Dynamic behaviour

  • htmx swaps just the panel (search-as-you-type keeps focus/scroll, no full reload), skeleton overlay that does not collapse height, error state with Retry, URL pushed on every change. Degrades to full-page GET without JS.
  • Keyboard: Tab reaches everything; segmented responds to arrow keys; sort headers activate on Enter (native) + Space (added); count is aria-live. Component CSS/JS inline (no stylesheet rebuild, no stale-cache risk). Light/dark/contrast.

Deliverable notes

Endpoint - did filter/sort already exist? Filtering (search/tier/verification/status) already existed from BUNYIP-410. Server-side sort did not and was added here: list_paginated gains a whitelisted sort_by + sort_desc with a stable id tiebreak, and active becomes tri-state so "All" returns both live and suspended rows. No client-side array filtering.

Bulk actions - my call: not built (deferred). There are no bulk API endpoints (suspend / resend-verification are per-user), so it needs backend work + selection UI + a contextual action bar. A checkbox column with no working actions is worse than a clean deferral. Recommend a follow-up ticket naming the exact bulk operations wanted; I'll wire the endpoints + selection bar then.

Real avatar photos - deferred to letter fallback. Serving another user's photo needs an admin avatar-serve endpoint (the current serve is self-only, /v1/users/me/avatar) plus an avatar field on the admin user response. The letter badge is in place; photos are a small follow-up.

Verification

  • just check-container (toolchain-less fmt + clippy -D warnings + build + test) green: 134 web tests, incl. the query-state URL builder, sort toggle, filter-resets-page, is-filtered, and a panel render (count + segmented + sortable headers + chips).
  • Sort SQL + tri-state status verified against a throwaway Postgres. No em-dashes in the diff.
  • Headless render of the redesigned panel attached below.

🤖 Generated with Claude Code

Rebuilds `/admin/users` from four stacked full-width controls into a real filter bar + sortable list. Before/after: the old page (ref_user.png) had ambiguous full-width selects showing "Verified"/"Early Adopter" as if chosen, a dead "Filter" button, and unstyled Active/Suspended buttons. ## Filter bar - One wrapping row: search grows to fill; **Verification** and **Tier** are the app's own `<details>` dropdowns (not native selects) at content width, each with a persistent prefix ("Verification: Any", "Tier: Any") and an explicit **Any** default shown on first load, so a filtered state is never ambiguous. - **Active / All / Suspended** is a single **segmented control** (radiogroup, one selected, arrow-key navigable). - No "Filter" button: filters apply on change; search is debounced 250ms. ## State + feedback - All filter/sort/page state is in the **URL** (linkable, refresh, back). - Applied filters render as removable **chips** with **Clear all** (only when non-default). - Heading reflects the segment; **count** shows "N of M users" via an `aria-live` region. Chips + count in reserved-height rows (no shift). ## List - Grid with column headers; **Email / Tier / Verification / Joined are sortable** (server-side, whitelisted, direction toggles, indicator on the active column, persisted in the URL). - **Letter-initial avatar** per row, absolute-date `title` on the relative "Joined", whole-row link with hover bg + focus-visible ring + chevron. - **Suspended rows** (surfaced on the new "All" segment via a new `suspended` flag) are not links and keep an inline **Reactivate**. - Prev/next pager + **page-size** control (10/20/50/100). ## Dynamic behaviour - htmx swaps just the panel (search-as-you-type keeps focus/scroll, no full reload), **skeleton overlay** that does not collapse height, **error state with Retry**, URL pushed on every change. Degrades to full-page GET without JS. - Keyboard: Tab reaches everything; segmented responds to arrow keys; sort headers activate on Enter (native) + Space (added); count is `aria-live`. Component CSS/JS inline (no stylesheet rebuild, no stale-cache risk). Light/dark/contrast. ## Deliverable notes **Endpoint - did filter/sort already exist?** Filtering (search/tier/verification/status) already existed from BUNYIP-410. Server-side **sort did not** and was added here: `list_paginated` gains a whitelisted `sort_by` + `sort_desc` with a stable `id` tiebreak, and `active` becomes tri-state so "All" returns both live and suspended rows. No client-side array filtering. **Bulk actions - my call: not built (deferred).** There are no bulk API endpoints (suspend / resend-verification are per-user), so it needs backend work + selection UI + a contextual action bar. A checkbox column with no working actions is worse than a clean deferral. Recommend a follow-up ticket naming the exact bulk operations wanted; I'll wire the endpoints + selection bar then. **Real avatar photos - deferred to letter fallback.** Serving another user's photo needs an admin avatar-serve endpoint (the current serve is self-only, `/v1/users/me/avatar`) plus an avatar field on the admin user response. The letter badge is in place; photos are a small follow-up. ## Verification - `just check-container` (toolchain-less fmt + clippy `-D warnings` + build + test) green: **134 web tests**, incl. the query-state URL builder, sort toggle, filter-resets-page, is-filtered, and a panel render (count + segmented + sortable headers + chips). - Sort SQL + tri-state status verified against a throwaway Postgres. No em-dashes in the diff. - Headless render of the redesigned panel attached below. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
feat(admin): overhaul the users filter bar and list (filters, sort, chips, states)
All checks were successful
E2E / Playwright against deployment (pull_request) Successful in 1m54s
Check / fmt + clippy + build + tests (pull_request) Successful in 8m12s
Create release / Create release from merged PR (pull_request) Has been skipped
e75ec83975
Rebuilds the /admin/users page from four stacked full-width controls into a proper filter bar + sortable list, addressing the ambiguity, layout, and usability complaints.

Filter bar (single wrapping row): the search input grows to fill; the Verification and Tier filters are the app's own `<details>` dropdowns (not native selects) at content width, each with a persistent prefix ("Verification: Any", "Tier: Any") so a filtered state is never ambiguous and every dropdown has an explicit "Any" default shown on first load. Active / All / Suspended is a single segmented control (radiogroup, one selected, arrow-key navigable). The "Filter" button is gone: filters apply on change, search is debounced 250ms.

State + feedback: all filter/sort/page state lives in the URL (linkable, refresh, back), applied filters render as removable chips with a "Clear all" that only appears when something is non-default, the heading reflects the segment ("Active users" / "All accounts" / "Suspended accounts"), and the count shows "N of M users" via an aria-live region. Chips and count sit in reserved-height rows so nothing shifts.

List: a grid with column headers where Email, Tier, Verification, and Joined are sortable (server-side, whitelisted columns, direction toggles on repeat click, indicator on the active column, sort persisted in the URL). Each row gains a letter-initial avatar, an absolute-date title on the relative "Joined" label, and is a whole-row link with hover background + focus-visible ring + chevron. Suspended rows (surfaced on the new "All" segment via a new `suspended` flag on the user response) are not links and keep an inline Reactivate. Prev/next pager + a page-size control (10/20/50/100).

Dynamic behaviour uses htmx to swap just the panel (search-as-you-type keeps focus and scroll, no full reload), with a skeleton overlay that does not collapse the container height, an error state with Retry, and the URL pushed on every change. It degrades to full-page GET navigation without JS. Keyboard: Tab reaches every control, the segmented control responds to arrow keys, sort headers activate on Enter (native) and Space (added), and the count is aria-live. Component CSS/JS are inline (no stylesheet rebuild, no stale-cache risk).

Endpoint: filtering (search / tier / verification / status) already existed from BUNYIP-410; server-side SORT did not and was added here (list_paginated gains a whitelisted `sort_by` + `sort_desc` with a stable `id` tiebreak, and `active` becomes tri-state so "All" returns both live and suspended rows). No client-side array filtering.

Bulk selection: intentionally NOT built. There are no bulk API endpoints (suspend / resend-verification are per-user), so it needs backend work plus selection UI plus a contextual action bar; a checkbox column with no working actions is worse than a clean deferral. Recommend a follow-up ticket that names the exact bulk operations. Real avatar photos are also deferred to a letter fallback: serving another user's photo needs an admin avatar endpoint (the current serve is self-only) and an avatar field on the admin user response.

Verified: `just check-container` (the toolchain-less fmt + clippy -D warnings + build + test) green, 134 web tests including the query-state URL builder, sort toggle, filter-resets-page, is-filtered, and a panel render (count + segmented + sortable headers + chips). The sort SQL and tri-state status were verified against a throwaway Postgres. No em-dashes in the diff.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01L5dcYueNHByRnWJDYoDX1W
longjacksonle scheduled this pull request to auto merge when all checks succeed 2026-07-30 00:00:08 +02:00
longjacksonle deleted branch feat/BUNYIP-410-users-filter-overhaul 2026-07-30 00:01:24 +02:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
psa-systems/bunyip!408
No description provided.