Skip to content

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

Merged
ianmuchyri merged 3 commits into
edgefrom
fix-object-group-picker-members
Aug 17, 2026
Merged

Hide already-joined items from object-group add pickers#84
ianmuchyri merged 3 commits into
edgefrom
fix-object-group-picker-members

Conversation

@ianmuchyri

Copy link
Copy Markdown
Contributor

What

The four search-to-add pickers introduced in #82 offered every candidate in the tenant, including the ones already joined:

  • entity inspect sheet → Object groups → "Add to object group"
  • resource inspect sheet → Object groups → "Add to object group"
  • object-group inspect sheet → Members → Entities tab → "Add entity"
  • object-group inspect sheet → Members → Resources tab → "Add resource"

addEntityToObjectGroup / addResourceToObjectGroup are idempotent, so clicking Add on 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 in lib/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 via useObjectGroupIds.
  • excludeJoinedMembers(candidates, groupId) — the group side now selects objectGroupIds on 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.

parentGroupId without includeDescendants matches direct membership rows in group_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 in membership.test.ts (including a candidate whose objectGroupIds is absent rather than empty), plus tests in both component suites asserting the picker offers exactly one Add (the non-joined row), that a joined item renders only once on screen, and that the "everything already joined" message appears with no Add button. 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 :3011 pointed at a running Atom stack, admin login, fixtures qa-test-group-2 containing qa-test-entity-1 and qa-test-resource-1):

Picker Before After
group → Entities tab "Add entity" offered qa-test-entity-3, qa-test-entity-1, bootstrap-gateway while qa-test-entity-1 was listed as the group's one member offers only qa-test-entity-3 and bootstrap-gateway
group → Resources tab "Add resource" offered qa-test-resource-1 (the group's one member) and telemetry offers only telemetry
entity sheet (qa-test-entity-1) "Add to object group" offered qa-test-group-2 (shown as joined in the chip row above), qa-test-group-1, production-channels offers only qa-test-group-1 and production-channels

Adding a genuinely new item still works end-to-end: clicking Add on qa-test-entity-3 raised the toast "qa-test-entity-3 added to this group", the member count went 1 entity → 2 entities, and the picker immediately dropped qa-test-entity-3, leaving only bootstrap-gateway — confirming the candidate-cache invalidation.

Browser console: no errors or warnings before or after the change.

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>
@ianmuchyri
ianmuchyri merged commit 30e2ca4 into edge Aug 17, 2026
@ianmuchyri
ianmuchyri deleted the fix-object-group-picker-members branch August 17, 2026 07:13
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