Skip to content

feat(ui): persist admin table filters across HTMX pagination and partial refresh#3647

Open
omorros wants to merge 5 commits intoIBM:mainfrom
omorros:feat/persist-admin-table-filters
Open

feat(ui): persist admin table filters across HTMX pagination and partial refresh#3647
omorros wants to merge 5 commits intoIBM:mainfrom
omorros:feat/persist-admin-table-filters

Conversation

@omorros
Copy link
Contributor

@omorros omorros commented Mar 12, 2026

🔗 Related Issue

Closes #2992 Replaces #3204 (could not push conflict resolution to upstream branch). Original work from #3017 and #3100.


📝 Summary

Persist admin table filters (search text, tag filters, "show inactive" toggles) across HTMX pagination clicks and
partial refreshes for all 6 admin tables.

Root cause: pagination_controls.html loadPage() built HTMX request URLs using template-baked query_params (static Jinja2 values). When pagination controls were rendered before a search, clicking page 2 dropped the active
filters.

Changes:

  • pagination_controls.htmlloadPage() now reads live namespaced q/tags from the browser URL (source of truth
    written by updatePanelSearchStateInUrl), overriding stale template-baked values.
  • admin.js — Added htmx:afterSettle listener that rehydrates search inputs from URL state after content swaps.
    Added updateFilterStatus() for visible filtered row count text. - admin.html — Added aria-live="polite" status spans next to all 6 table search bars (servers, tools, resources,
    prompts, gateways, a2a-agents).

🏷️ Type of Change

  • Bug fix
  • Feature / Enhancement
  • Documentation
  • Refactor
  • Chore (deps, CI, tooling)
  • Other (describe below)

🧪 Verification

Check Command Status
JS tests (vitest) npx vitest run 583/584 pass (1 pre-existing locale mismatch)
Unit tests make test 12,349/12,355 pass (6 pre-existing stdio failures)
Lint suite make lint N/A — no Python files changed
Coverage ≥ 80% make coverage N/A — no Python files changed

✅ Checklist

  • Code formatted (make black isort pre-commit)
  • Tests added/updated for changes
  • Documentation updated (if applicable)
  • No secrets or credentials committed

📓 Notes (optional)

Rebased on latest main to resolve merge conflicts from #3204. The code and functionality are identical to the
previously approved PR.

@omorros omorros requested a review from crivetimihai as a code owner March 12, 2026 11:00
@marekdano marekdano added this to the Release 1.0.0 milestone Mar 12, 2026
@marekdano marekdano added enhancement New feature or request ui User Interface SHOULD P2: Important but not vital; high-value items that are not crucial for the immediate release release-fix Critical bugfix required for the release merge-queue Rebased and ready to merge labels Mar 12, 2026
marekdano
marekdano previously approved these changes Mar 12, 2026
Copy link
Collaborator

@marekdano marekdano left a comment

Choose a reason for hiding this comment

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

Thanks for the PR, @omorros!
This PR correctly fixes the filter persistence issue across pagination with a clean, well-architected solution.

🎯 Key Strengths

  1. Root Cause Correctly Identified: The issue was that loadPage() in pagination_controls.html used template-baked query_params (static Jinja2 values rendered at page load), which became stale after user interactions.

  2. Elegant Solution: The fix establishes a clear data flow:

  • Source of Truth: Browser URL (written by updatePanelSearchStateInUrl())
  • Pagination: Reads live state from URL via currentUrlParams.get(livePrefix + 'q')
  • Rehydration: htmx:afterSettle listener restores input values after HTMX swaps
  1. Defense-in-Depth: The fix includes multiple layers:
  • Primary: Read live URL params in loadPage() (lines 139-151 in pagination_controls.html)
  • Fallback: Still reads from input elements (lines 115-137) for backward compatibility
  • Rehydration: Restores inputs after HTMX swaps (lines 18743-18760 in admin.js)
  1. User Feedback: Added updateFilterStatus() with aria-live regions for accessibility

📋 Technical Review

pagination_controls.html (+13 lines):

✅ Correctly reads namespaced params (e.g., servers_q, tools_tags)
✅ Overrides stale template values
✅ Preserves team_id for multi-tenancy

admin.js (+65 lines):

  1. updateFilterStatus() (lines 18703-18738):
  • ✅ Reads URL params to detect active filters
  • ✅ Displays filtered count from pagination controls
  • ✅ Falls back to "Filters active" if count unavailable
  • ✅ Clears status when no filters active
  1. htmx:afterSettle listener (lines 18743-18760):
  • ✅ Triggers on table/pagination swaps
  • ✅ Calls resetSearchInputsState() + initializeSearchInputsMemoized()
  • ✅ Updates filter status after rehydration

admin.html (+6 lines):

  • ✅ Added to all 6 tables
  • ✅ Proper aria-live="polite" and role="status" for accessibility

🔍 Minor Observations

  1. Redundant Input Reading: Lines 115-137 in pagination_controls.html still read from input elements before the URL override. This is harmless (URL wins) but could be simplified in a future refactor.

  2. Event Listener Scope: The htmx:afterSettle listener triggers on any element ending with -table, -table-body, -list-container, or -pagination-controls. This is broad but safe since initializeSearchInputsMemoized() is memoized.

  3. Filter Status Timing: updateFilterStatus() reads pagination totals from Alpine-rendered elements. If Alpine hasn't rendered yet, it falls back to "Filters active" (acceptable).

✅ Verification Checklist

  • ✅ Fixes root cause (stale template-baked params)
  • ✅ Maintains backward compatibility (still reads inputs as fallback)
  • ✅ Handles all 6 admin tables (servers, tools, resources, prompts, gateways, a2a-agents)
  • ✅ Preserves team_id for multi-tenancy
  • ✅ Accessibility (aria-live regions)
  • ✅ Clean, minimal changes (84 additions, 0 deletions)
  • ✅ No breaking changes

This PR correctly fixes the filter persistence bug with a well-architected solution. The redundant input reading is harmless and can be cleaned up in a future refactor if desired.

LGTM 🚀

omorros and others added 2 commits March 17, 2026 21:54
…ial refresh Closes IBM#2992

Signed-off-by: Oriol Morros Vilaseca <OM368@student.aru.ac.uk>
…agination (IBM#3647)

Add 34 tests covering the new filter persistence code:

- updateFilterStatus: filter-status span updates, per-table independence,
  pagination text integration, no-op safety
- htmx:afterSettle rehydration listener: target ID matching for table,
  table-body, list-container, pagination-controls suffixes; null/empty guards
- pagination_controls URL-param filter reading: namespaced q/tags reading,
  extraParams override precedence, team_id preservation, namespace isolation

Signed-off-by: Mihai Criveti <crivetimihai@gmail.com>
@crivetimihai
Copy link
Member

Maintainer Review

Rebased onto latest main and resolved a merge conflict in pagination_controls.html.

Conflict Resolution

Main recently added DOM-based search input reading in loadPage() (fix for #3128), which conflicts with this PR's URL-based approach. Kept the URL-based approach because:

  1. The DOM approach had an ID mismatch bug: it used tableName + '-search-input' (e.g., servers-search-input) but the catalog panel's actual input ID is catalog-search-input. This means search filters were silently dropped during pagination for at least the servers/catalog tab.
  2. URL is the source of truth: updatePanelSearchStateInUrl() is always called before HTMX requests, so reading from URL params is both simpler and more reliable.

Test Coverage Added

Added 34 unit tests in admin-pagination.test.js covering all new code:

  • updateFilterStatus() — 9 tests: clear/active filter states, pagination text integration, per-table independence, no-op safety for missing elements
  • htmx:afterSettle rehydration — 7 tests: all target suffix patterns (-table, -table-body, -list-container, -pagination-controls), unrelated targets, null/empty guards
  • URL-param filter reading — 12 tests: namespaced q/tags reading, extraParams override precedence, team_id preservation, namespace isolation between tables, empty value handling

Full test suite: 899/899 JS tests pass, 0 regressions.

Review Notes

  • No security concerns — uses textContent (not innerHTML), no user input rendered as HTML
  • No performance concerns — iterates 6-item config, no heavy operations
  • Accessibility: aria-live="polite" and role="status" on filter-status spans is correct usage
  • Consistent with existing patterns (PANEL_SEARCH_CONFIG, createMemoizedInit, namespaced URL params)

…ate (IBM#3647)

Add flushPendingSearchState() to close the 250ms debounce race window:
when a user types in a search input and clicks pagination before the
debounced URL update fires, loadPage() now flushes pending timers first
so the URL reflects the live input values.

Signed-off-by: Mihai Criveti <crivetimihai@gmail.com>
crivetimihai
crivetimihai previously approved these changes Mar 17, 2026
Copy link
Member

@crivetimihai crivetimihai left a comment

Choose a reason for hiding this comment

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

LGTM — rebased, reviewed, and hardened.

Changes made on top of the original commit:

  1. Conflict resolution — kept URL-based filter reading (correct), dropped main's DOM-based approach (had catalog/a2a-agents input ID mismatch bug)
  2. Debounce race fix — added flushPendingSearchState() so paginating within the 250ms debounce window still picks up live input values via PANEL_SEARCH_CONFIG (correct IDs for all 6 tables)
  3. 40 new testsupdateFilterStatus, htmx:afterSettle rehydration, URL-param filter reading, and flush function coverage

905/905 JS tests pass, all linters clean.

… contexts (IBM#3647)

Add getLiveSearchState() that reads DOM inputs via PANEL_SEARCH_CONFIG
(correct IDs for all 6 tables including catalog and a2a-agents). Used as
a fallback by loadPage() when URL params are empty — covers sandboxed
iframe contexts where safeReplaceState is a no-op and the URL is never
updated.

Signed-off-by: Mihai Criveti <crivetimihai@gmail.com>
@crivetimihai
Copy link
Member

Additional fix: DOM fallback for restricted iframe contexts

On review, the URL-only approach would regress filter persistence in embedded/iframe deployments where safeReplaceState() is a no-op (the URL never gets updated, so reading from it returns nothing).

Fix: Added getLiveSearchState(tableName) which reads DOM inputs via PANEL_SEARCH_CONFIG — this uses the correct input IDs for all 6 tables (including catalog-search-input for servers and a2a-agents-search-input for agents). loadPage() now uses a layered strategy:

  1. Flush any pending debounce timers to URL (flushPendingSearchState)
  2. Read from URL (primary — works in normal contexts)
  3. DOM fallback (getLiveSearchState) — kicks in when URL params are empty, covering restricted iframe contexts

14 additional tests covering getLiveSearchState (correct ID mapping for all tables including catalog/a2a-agents edge cases) and the DOM fallback logic.

919/919 JS tests pass (1 pre-existing flaky gantt-chart test occasionally fails in parallel runs, passes in isolation).

…back (IBM#3647)

Invert the precedence in loadPage(): read DOM inputs first (always live,
works in restricted iframe contexts where safeReplaceState is a no-op
and the URL stays stale), fall back to URL params only when DOM inputs
have no values (fresh page load from bookmarked URL before inputs are
populated).

This fixes the case where the URL has stale tools_q=alpha but the user
has typed beta — previously URL took precedence and the DOM fallback
was skipped because liveQuery was truthy.

Signed-off-by: Mihai Criveti <crivetimihai@gmail.com>
@crivetimihai
Copy link
Member

Fix: DOM-first precedence for filter reads

The previous layering was wrong — URL params took precedence over DOM inputs, so in restricted iframe contexts where safeReplaceState is a no-op, a stale URL value (e.g., tools_q=alpha) would win over the live DOM input (e.g., "beta" that the user just typed).

Inverted the precedence in loadPage():

  1. Primary: DOM inputs via getLiveSearchState(tableName) — always reflects what the user actually typed, works in all contexts
  2. Fallback: URL params — only used when DOM inputs are empty (fresh page load from a bookmarked URL before initializeSearchInputs populates inputs)

Tests updated: the old test "URL params take precedence over DOM fallback" is now correctly "DOM takes precedence over stale URL params (restricted context fix)". Added test for "DOM wins over both stale URL and stale extraParams".

920/920 JS tests pass.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request merge-queue Rebased and ready to merge release-fix Critical bugfix required for the release SHOULD P2: Important but not vital; high-value items that are not crucial for the immediate release ui User Interface

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[EPIC][UI]: Persist admin table filters across HTMX pagination and partial refresh

3 participants