Skip to content

Fix pagination footer ignoring the active list filter - #85

Merged
ianmuchyri merged 1 commit into
edgefrom
fix-crud-filtered-pagination-total
Aug 17, 2026
Merged

Fix pagination footer ignoring the active list filter#85
ianmuchyri merged 1 commit into
edgefrom
fix-crud-filtered-pagination-total

Conversation

@ianmuchyri

Copy link
Copy Markdown
Contributor

Problem

On every CRUD list page, typing into the filter box narrowed the displayed rows but left the pagination footer showing the unfiltered count.

Searching /entities for a term matching one entity showed a single row above a footer reading 350 rows … Page 1 of 18. /groups behaved identically (94 rows … Page 1 of 5).

Root cause

The search box was never wired to the backend. It writes <resource>.q into the URL, but CrudWorkspace only forwarded resource.filters entries and status to GraphQL — never q. So the list query always fetched an unfiltered page, and DataTable narrowed those rows in the browser:

// Client-side filter of the current page — useful when backend has no text search.
const filteredData = data.filter((row) => {  });

The footer then printed the server's total, which still counted the whole unfiltered set. Rows and count came from two different notions of "the result set".

This also meant search only ever matched rows that were already on the visible page — searching for something on page 5 while viewing page 1 found nothing.

Fix

  • Forward the search term as q on the list queries whose resolver accepts it — tenants, entities, groups, resources, roles — so the backend narrows rows and total together and the filtered set pages correctly.
  • Pass serverFilters down to DataTable so it knows which filters were already resolved server-side. It skips re-filtering those (re-applying the browser's broader JSON.stringify match would drop valid backend matches).
  • For resources with no backend text search (profiles, permissionBlocks, actions, actionApplicability, actionAssignmentRules, directPolicies) the client-side fallback stays, but the footer now counts the rows it actually filtered instead of reporting a total that no longer describes them.
  • Clamp the displayed page to the available range so a collapsed result set cannot render Page 7 of 1.

Fixed in the one shared component, so it applies to every list page.

Scope note

This bug predates #69 and #82 — neither caused it. It was found while QA-testing their UI, not introduced by them. #69 is Rust/GraphQL backend only, and #82 touched only the inspect sheets, object-group-*, object-access-panel.tsx, lib/access/ and lib/object-groups/ — none of the shared list/table/pagination plumbing. The client-side filter block responsible dates back to 40e1512 "Add initial admin UI (#1)".

Test plan

Manual (browser, dev UI on :3012 against the running backend on :8080, ~350 entities / 94 groups)

Before the fix:

Page Filter Rows shown Footer
/entities qa-test-entity-3 1 350 rows, Page 1 of 18
/groups qa-test-group-2 1 94 rows, Page 1 of 5

After the fix:

Page Filter Rows shown Footer
/entities qa-test-entity-3 1 1 row, Page 1 of 1
/groups qa-test-group-2 1 1 row, Page 1 of 1

Regression checks, all after the fix:

  • Unfiltered still correct/entities 350 rows / Page 1 of 18, /groups 94 rows / Page 1 of 5, matching the pre-fix unfiltered baseline.
  • Unfiltered paging/entities?entities.page=2Page 2 of 18, 20 different rows, all four page links enabled.
  • Filtered paging across pages/entities?entities.q=tenant&entities.limit=1012 rows / Page 1 of 2, 10 rows, Next preserves q; page 2 → Page 2 of 2, remaining 2 rows, search box still shows tenant, Next/Last correctly disabled.
  • Clearing the filter — typed then cleared in the box on /groups; returned to 94 rows / Page 1 of 5 with paging re-enabled.
  • Other newly-$q resources/tenants?q=demo 2 rows, /roles?q=admin 57 → 6 rows, /resources?q=telemetry 1 row. No Backend unavailable alerts.
  • Client-side fallback intact/profiles?profiles.q=Gateway (no backend q) still filters to 1 row and the footer reports 1 row.
  • Console — no new errors or warnings.

Automated

  • pnpm lint — clean (178 files).
  • pnpm test — 25 files, 103 tests passing.
  • pnpm build — compiles successfully.

Added 5 tests to components/ui/data-table.test.tsx. Verified they are real regression tests: reverting data-table.tsx to its pre-fix state fails 4 of them with exactly the reported symptom (expected 'ViewNameneedle350 rows…' to contain '1 row'), and they pass with the fix.

The CRUD list pages never sent the search box term to the backend. Every
list query fetched an unfiltered page and `DataTable` narrowed the rows in
the browser, so the footer printed the unfiltered `total` next to a handful
of visible rows — "350 rows... Page 1 of 18" above a single matching entity.
Paging was broken the same way: the search only ever matched rows that
happened to be on the page already on screen.

Forward the search term as `q` on the list queries whose resolver accepts it
(tenants, entities, groups, resources, roles) so the backend narrows the rows
and `total` together and the filtered set pages properly. Tell the table which
filters were resolved server-side; it skips re-filtering those and, for the
resources with no backend text search, counts the rows it filtered itself
rather than reporting a total that no longer describes them.

Also clamp the displayed page to the available range so a collapsed result
set cannot render "Page 7 of 1".

Signed-off-by: ianmuchyri <ianmuchiri8@gmail.com>
@ianmuchyri
ianmuchyri merged commit 32ae65e into edge Aug 17, 2026
@ianmuchyri
ianmuchyri deleted the fix-crud-filtered-pagination-total branch August 17, 2026 07:16
drasko pushed a commit that referenced this pull request Aug 17, 2026
* Add attributesContains filter to entities and groups queries (#63)

* Add objectId reverse lookup to directPolicies query (#64)

* Make object group membership many-to-many (#65)

* Add external_id to entities, unique per tenant (#66)

* Expose scoping filters on authorizedObjectIds (#68)

* Fix blank externalId filter and no-op object-group removal events (#77)

* Keep a blank externalId filter distinct from an omitted one

normalize_external_id collapsed both "filter omitted" and "filter
explicitly blank/whitespace" to None. The SQL callers bind that as
`col IS NULL OR col = $n`, so a caller who passed a blank externalId
filter (a mistake, or an attempt to match nothing) got back the full
unfiltered authorized/listed set instead of zero rows.

Every Some(_) input now stays Some(_): blank, NUL-bearing, and
over-cap values normalize to a sentinel longer than
MAX_EXTERNAL_ID_LEN, which the length CHECK constraint guarantees no
stored row can equal. Only a genuinely omitted None still means "no
filter".

Signed-off-by: ianmuchyri <ianmuchiri8@gmail.com>

* Suppress object-group membership events on no-op removals

remove_entity_from_object_group, clear_entity_object_groups, and
their resource counterparts discarded the DELETE's affected-row
count and always published entity.object_group.remove /
entity.object_groups.clear (and the resource equivalents), even when
zero rows were deleted — e.g. removing a membership that never
existed, or clearing an already-empty set. Event consumers were told
a change happened when nothing changed.

delete_entity_object_groups_in_tx and
delete_resource_object_groups_in_tx now return the deleted row
count, and the four *_with_audit callers skip the event publish (but
still commit) when it's zero — the same shape already used by
add_entity_to_object_group_with_audit / add_resource_to_object_group_with_audit
for the additive side, and by remove_group_member_with_audit for
principal-group membership.

Signed-off-by: ianmuchyri <ianmuchiri8@gmail.com>

---------

Signed-off-by: ianmuchyri <ianmuchiri8@gmail.com>

* Surface object-group membership and reverse policy lookup in the UI (#82)

* Surface object-group membership and reverse policy lookup in the UI

ATOM-04 (many-to-many object-group membership) and ATOM-03
(directPolicies(objectId:) reverse lookup) both shipped backend-only.
The admin panel never grew a way to reach either, so object groups had
no membership UI at all and there was no way to ask who an entity or
resource is shared with. This closes that gap against the existing
schema; no resolver changes.

Object groups get a Members panel in the inspect sheet, mirroring the
principal-group branch rather than replacing it. There is no "members
of this object group" query, so it reads the list filters that ATOM-04
made work — entities(parentGroupId:) and resources(parentGroupId:) —
under an Entities/Resources tab pair, each with a tenant-scoped
search-to-add picker and a per-row remove.

The same membership is editable from the other side: entity and
resource details now carry an Object groups section built from the
objectGroupIds the list query already selects, so opening the sheet
costs no extra round trip. Names resolve through the existing
useNameMap. Groups are picked from objectGroups(...) rather than
groups(...) so principal groups can never appear. clearEntityObjectGroups
/ clearResourceObjectGroups are exposed separately, behind a
confirmation, since they drop every membership at once.

Both directions share lib/object-groups: one set of documents and one
mutation hook, so the two views cannot drift. The mutations return the
mutated object, so its objectGroupIds seed the cache directly instead
of forcing a refetch. Adds and removes are idempotent server-side
(ON CONFLICT DO NOTHING, zero-row delete), so there is deliberately no
client-side "already a member" guard to go stale.

Entities and resources also gain a read-only Access tab over
directPolicies(objectId:, objectKind:). Per the resolver's own doc
comment this lists only policies whose permission block names the
object — role-derived access and platform/tenant/kind-wide grants are
excluded — so each row says why it reaches this object. Creating a
grant still belongs to the Policies page.

Signed-off-by: ianmuchyri <ianmuchiri8@gmail.com>

* fix(ui): hide object group add picker without tenant scope

Signed-off-by: ianmuchyri <ianmuchiri8@gmail.com>

---------

Signed-off-by: ianmuchyri <ianmuchiri8@gmail.com>

* Hide already-joined items from object-group add pickers (#84)

* Hide already-joined items from object-group add pickers

The four search-to-add pickers added with the object-group membership UI
offered every candidate in the tenant, including the ones already joined.
Adding one of those is a server-side no-op, so the picker showed an Add
button that changed nothing and still reported success — and nothing in
the row said the item was already a member.

Both directions now filter on `objectGroupIds`: the entity/resource side
already holds its own membership set, and the group-side candidates query
selects each candidate's set, so neither picker needs an extra round trip.
The candidate cache is invalidated alongside the members cache, since an
add or remove is exactly what makes those rows stale. When filtering
empties a non-empty result, the picker says every match is already joined
rather than claiming there is nothing to find.

Signed-off-by: ianmuchyri <ianmuchiri8@gmail.com>

* Fix object group picker pagination for joined members

Signed-off-by: ianmuchyri <ianmuchiri8@gmail.com>

* Fix object group picker pagination for joined groups

Signed-off-by: ianmuchyri <ianmuchiri8@gmail.com>

---------

Signed-off-by: ianmuchyri <ianmuchiri8@gmail.com>

* Fix pagination footer ignoring the active list filter (#85)

The CRUD list pages never sent the search box term to the backend. Every
list query fetched an unfiltered page and `DataTable` narrowed the rows in
the browser, so the footer printed the unfiltered `total` next to a handful
of visible rows — "350 rows... Page 1 of 18" above a single matching entity.
Paging was broken the same way: the search only ever matched rows that
happened to be on the page already on screen.

Forward the search term as `q` on the list queries whose resolver accepts it
(tenants, entities, groups, resources, roles) so the backend narrows the rows
and `total` together and the filtered set pages properly. Tell the table which
filters were resolved server-side; it skips re-filtering those and, for the
resources with no backend text search, counts the rows it filtered itself
rather than reporting a total that no longer describes them.

Also clamp the displayed page to the available range so a collapsed result
set cannot render "Page 7 of 1".

Signed-off-by: ianmuchyri <ianmuchiri8@gmail.com>

---------

Signed-off-by: ianmuchyri <ianmuchiri8@gmail.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant