Hide already-joined items from object-group add pickers - #84
Merged
Conversation
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>
Signed-off-by: ianmuchyri <ianmuchiri8@gmail.com>
Signed-off-by: ianmuchyri <ianmuchiri8@gmail.com>
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What
The four search-to-add pickers introduced in #82 offered every candidate in the tenant, including the ones already joined:
addEntityToObjectGroup/addResourceToObjectGroupare idempotent, so clickingAddon an already-joined row was harmless — but it fired a mutation and raised a success toast for something that changed nothing, and the picker gave no way to tell joined from not-joined.How
Both directions decide from
objectGroupIds, and the filtering lives in one place — two helpers inlib/object-groups/membership.ts, shared by the two picker components (each of which serves two of the four pickers):excludeJoinedGroups(options, joinedGroupIds)— the entity/resource side already holds its own membership set viauseObjectGroupIds.excludeJoinedMembers(candidates, groupId)— the group side now selectsobjectGroupIdson each candidate in the existing candidates query, so no extra round trip and no dependence on the paginated members list (which only holds the current page of 10).The candidates cache is invalidated alongside the members cache on add/remove/clear, since a membership mutation is exactly what makes those candidate rows stale.
When filtering empties an otherwise non-empty result, the picker now says so ("Every matching entity is already in this group." / "Already a member of every matching object group.") instead of claiming there is nothing to find.
parentGroupIdwithoutincludeDescendantsmatches direct membership rows ingroup_entity_parents, so the filter and the members list agree exactly.Test plan
Automated, in
app/:pnpm lint— clean (biome, 178 files).pnpm test— 25 files, 107 tests pass. New coverage: unit tests for both helpers inmembership.test.ts(including a candidate whoseobjectGroupIdsis absent rather than empty), plus tests in both component suites asserting the picker offers exactly oneAdd(the non-joined row), that a joined item renders only once on screen, and that the "everything already joined" message appears with noAddbutton. Reverting just the two component changes fails 11 of the 13 tests in those two suites, so the new assertions are not vacuous.pnpm build— succeeds.Manual, against a real backend (own dev UI on
:3011pointed at a running Atom stack, admin login, fixturesqa-test-group-2containingqa-test-entity-1andqa-test-resource-1):qa-test-entity-3,qa-test-entity-1,bootstrap-gatewaywhileqa-test-entity-1was listed as the group's one memberqa-test-entity-3andbootstrap-gatewayqa-test-resource-1(the group's one member) andtelemetrytelemetryqa-test-entity-1)qa-test-group-2(shown as joined in the chip row above),qa-test-group-1,production-channelsqa-test-group-1andproduction-channelsAdding a genuinely new item still works end-to-end: clicking
Addonqa-test-entity-3raised the toast "qa-test-entity-3 added to this group", the member count went 1 entity → 2 entities, and the picker immediately droppedqa-test-entity-3, leaving onlybootstrap-gateway— confirming the candidate-cache invalidation.Browser console: no errors or warnings before or after the change.