Skip to content

feat(dot-roles): Tools tab in Angular, roles service consolidation, and edit-gate fixes - #37260

Open
hmoreras wants to merge 12 commits into
mainfrom
issue-36930-follow-up
Open

feat(dot-roles): Tools tab in Angular, roles service consolidation, and edit-gate fixes#37260
hmoreras wants to merge 12 commits into
mainfrom
issue-36930-follow-up

Conversation

@hmoreras

@hmoreras hmoreras commented Aug 28, 2026

Copy link
Copy Markdown
Member

Proposed Changes

role-portlet-progress.mp4

Follow-up work on the Roles (Beta) Angular portlet.

Consume the backends that landedGET /v1/roles/{roleId}/users (#37070) replaces the previous pair of member-loading calls. The old id-based /rolehierarchyanduserroles fallback returned Role objects rather than users, which is why the Users tab rendered an empty EMAIL column; that column now populates. childCount and userCount (#37071) drive leaf-vs-chevron detection on first paint — no more chevrons that expand into nothing — and unblock the per-row user-count badge the design called for.

Worth flagging: the code's comments predicted /roles/{id}/users would resolve inheritance server-side and collapse the whole flow into one call. It shipped as a direct grants only resource by design, so the ancestor-chain fan-out stays and is now documented as permanent rather than as a stopgap. For the same reason the members table keeps client-side paging — a server page of one ancestor is not a page of the merged union, so [lazy]="true" would be incorrect, not pending.

Migrate the Tools tab to Angular — replaces the Dojo iframe. Scope is deliberately narrower than the JSP it replaces: it lists tool groups and toggles which ones the role gets. Creating, editing and deleting tool groups is not here — those have no v1 endpoints (only RoleAjax DWR) and belong to a Tools portlet of their own. Inherited grants render checked but locked; echoing one back into the full-replace POST would silently promote it to a direct grant. The iframe component and its two JSPs are deleted rather than left behind.

Consolidate the roles surface into DotRolesService — three libs had grown their own implementation of the same /api/v1/roles/** surface, and three TypeScript models of the same backend RoleView. Reads, writes and models now live in one place; method names drop the redundant role (createRolecreate). get and search keep their legacy names — the workflow assign components inject this service and those paths are untouched. The full-hierarchy walk did not move into the service: it is one way of composing two endpoints for one UI, so it lives as a pure, HTTP-free function in dot-users, and it now prunes on childCount — most roles below the first level are leaves, which removes the large majority of the request burst the Roles tab produced on open.

Fix edit gates that were stricter than the backend — the Tools tab greyed itself out for CMS Administrator while the legacy portlet allows the edit. RoleHelper applies a different contract per operation: update/delete checks isSystem() || isLocked(), user grants check isEditUsers() alone, and saving layouts places no restriction on the target role at all.

Review fixes

A multi-agent review and hands-on QA surfaced defects that are fixed in this PR:

  • Two cross-role races. saveToolGroups and the user grant/remove flows captured the role id before their await and reconciled with it afterwards, so a role switch mid-flight repainted the new role's tab with the old role's data. For tool groups this was worse than cosmetic: the tab derives its next POST payload from toolGroups, so one role's grants could be written onto another. Guarded at both ends, with regression tests verified by neutering each guard and confirming they go red.
  • "Could not verify" was being rendered as "not granted". A per-ancestor catchError returned an empty set on failure, so a transient error showed an inherited tool group unchecked — and an admin trusting the grid would create a redundant direct grant. Failures now surface instead of being absorbed.
  • childCount regressions. Local tree mutations left it stale, so a parent that just gained its first child rendered as a leaf and the new role was unreachable. And POST /v1/roles answers with Role.toMap(), which carries no childCount, so a freshly created role drew as a folder.
  • The tree user-count badge never refreshed after a grant or revoke. It now syncs from the direct-grant count.
  • dot-users-list.store.spec.ts still mocked a method deleted in this PR and never mocked DotRolesService, so the roles column ran against the real root service and was effectively untested.

Add / Edit role dialogs

Required markers use the dotFieldRequired directive (which is what makes them red) instead of hand-written text, and dot-field-validation-message is wired in — there was no validation feedback at all. The dialog title moves from an h2 in the body into the dialog header.

Parent selection becomes a p-treeSelect: indentation alone gets ambiguous past two levels. It starts collapsed, hydrates a branch on expand (the backend sends two levels per request), and its filter runs the same deep search the roles tree uses, so a role in an unloaded branch is findable.

Two PrimeNG behaviours needed working around, both from one cause — the component keeps state inside the node objects it is given, and these options come from a computed that hands it new ones. Expansion is mutated onto node.expanded, so branches snapped shut the moment their children loaded. And Tree.getRootNode() returns its cached filteredNodes once the client filter has run, ignoring value, so server results never rendered. Both are handled explicitly, with the reasoning in the code — the second reaches one internal method, which is the most upgrade-fragile line in this PR.

Design review

Empty states now use the shared DotEmptyContainerComponent; the hand-rolled dashed card they replaced exists nowhere else in the product. shieldshield_person. The + on a tree row is primary with a pointer cursor. Untranslated portlet titles no longer print as raw i18n keys.

The user-count badge takes a real grey. text-color-secondary is a PrimeFlex-era class — PrimeFlex is not installed and tailwindcss-primeui does not provide it either, so it compiles to nothing and elements inherit the label's near-black. Verified against the built CSS rather than assumed. 104 more occurrences survive across 35 files and need their own pass.

Checklist

  • Tests
  • Translations
  • Security Implications Contemplated (add notes if applicable)

Additional Info

Verification — 36 projects linted, 35 tested, nx format:check clean, and the dotcms-ui production build green. The dot-roles suite is at 136 tests.

Security note. While tracing the tool-group endpoints, GET /api/v1/roles/layouts turned out to have no authentication gate — the only one of 16 endpoints in RoleResource without a WebResource.InitBuilder block. Confirmed at runtime: it returns 200 with the full tool-group catalog to an unauthenticated caller, while three sibling endpoints return 401 under identical conditions. It predates this work (introduced 2023-05-26, shipped since v23.06) and is filed separately as #37259. This PR does not change that behaviour, but it does put the endpoint on a more exercised path.

Standards note. ANGULAR_STANDARDS.md asks for Signal Forms on new forms, but the standard validation components are Reactive-Forms-bound — dotFieldRequired injects FormGroupDirective and dot-field-validation-message takes an AbstractControl. These dialogs stay on Reactive Forms; adopting Signal Forms would mean dropping both.

Follow-ups not included here

  • loadRootRoles(true)false is now possible thanks to childCount, and would lighten the initial payload considerably on installs with many roles. It changes lazy-load behaviour, so it deserves measuring on its own.
  • onNodeExpand marks a node fetched before the load resolves, so a failed lazy-load never retries.
  • Creating a role under a parent that is not currently loaded splices into a node that is not there — the role is created but does not appear until a reload.
  • The text-color-secondary sweep described above.

🤖 Generated with Claude Code

This PR fixes: #36930

hmoreras and others added 6 commits August 27, 2026 13:35
Wires the Beta portlet to the two backend features that landed on main and
that the code was explicitly waiting on.

#37070 — GET /v1/roles/{roleId}/users

Replaces the pair of member-loading calls: /v1/users/filter?roleKey=X (fast
but unusable on roles created without a roleKey) and the id-based
/rolehierarchyanduserroles fallback (worked by id but returned Role objects,
so the Users tab rendered an empty EMAIL column). The new endpoint is keyed
on roleId AND returns the standard user serialization, so both problems go
away and every member row now carries an email.

The endpoint shipped as a direct-grants-only resource: it deliberately does
not resolve inheritance or denormalize granted-from metadata. The code's
comments predicted the opposite ("a single call replacing this whole flow"),
so they are corrected here. The ancestor-chain fan-out that composes
effective membership stays, and is now documented as permanent by design
rather than as a stopgap. For the same reason the members table keeps
client-side paging: a server page of one ancestor is not a page of the
merged union, so [lazy]="true" would be incorrect, not pending.

Removes the now-dead roleKey branching along the whole path, plus the
toRoleMemberResults adapter and the RoleHierarchyEntry model. None of these
were exported from the lib, so the change is contained to the portlet.

#37071 — childCount / userCount on RoleView

Leaf detection now reads childCount instead of inferring from an empty
roleChildren array, so the chevron is right on first paint at every depth —
no more chevrons that expand into nothing at level 2+. Legacy search nodes
(/api/role/loadbyname) do not carry the field and fall back to the previous
heuristic.

userCount unblocks the per-row user-count badge the design called for, which
had been left out because no endpoint exposed the number.

Tests: 11 suites / 111 passing. Prod build, lint and format:check clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three libs had grown their own implementation of the same
`/api/v1/roles/**` surface, and three TypeScript models of the same
backend `RoleView`. The duplication was accidental — the same two pieces
of backend knowledge (two-level hydration, `_search` returning
`SmallRoleView` with no parent) had been independently rediscovered and
re-documented in two of them.

One model

`DotRole` in `@dotcms/dotcms-models` is now the single role shape. Only
`id` and `name` are guaranteed, because the surface is served by three
serializers that each omit part of the shape — documented on the type so
consumers stop assuming `roleKey` is an identifier or that an absent
`childCount` means zero.

`DotRoleView` is deleted from `dot-users.service.ts`; `DotRoleNode` and
`DotRoleDetail` become aliases, keeping the names that read well at the
tree's call sites. The lowercase `dbfqn` / `fqn` that `dot-users`
declared never matched the wire (Jackson serializes `getDBFQN()`
all-caps) and were never read; the unified model keeps the real spelling.

One read surface

`DotRolesService` gains `getRoots`, `getById` and `getForUser` alongside
the existing `get` / `search`. Names drop the redundant `role` — the
service already says it. `dot-users` and the `dot-roles` portlet both
repoint at it; `DotRolesPortletService` keeps CRUD, the legacy
`loadbyname` search and the write adapters, since promoting destructive
operations to workspace-public API would freeze the wire shape while
those endpoints are still landing.

`get` / `search` / `processRolesResponse` are deliberately untouched.
The workflow assign components inject this service, so leaving those
paths byte-for-byte identical keeps that blast radius at compile-time
only.

Strategy is not an endpoint

The full-hierarchy walk does NOT move into the shared service. It is one
way of composing two endpoints, chosen for one UI: the Roles tab renders
a shuttle and needs every role up front, while the roles portlet renders
a tree and composes the same endpoints lazily per expansion. Neither
belongs to the service. It now lives as a pure function in
`dot-users/utils/dot-roles-hierarchy.utils.ts`, taking a `fetchChildren`
callback so it is unit-testable without HTTP.

Two behaviors changed while it moved:

- It only descends into nodes reporting `childCount > 0` (#37071). Most
  roles below the first level are leaves, so this prunes the large
  majority of the request burst the Roles tab produced on open. Nodes
  with an absent `childCount` are still visited, so an older serializer
  degrades to the exhaustive walk rather than silently truncating.
- The per-node `catchError(() => of({children: []}))` is gone. A partial
  hierarchy rendered as if it were complete is worse than a visible
  failure; the error now reaches the tab, which already routes it
  through DotHttpErrorManagerService.

Verified: 35 affected projects tested, 36 linted, format:check and the
dotcms-ui production build clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Replaces the legacy Dojo iframe with a real Angular tab. The endpoints it
needs already exist, so nothing was blocking this.

Scope is deliberately narrower than the JSP it replaces: the tab lists the
tool groups and toggles which ones the role gets. Creating, editing and
deleting tool groups is not here — those have no v1 endpoints (only RoleAjax
DWR) and belong to a Tools portlet of their own.

Composition

Three endpoints, none of them new:

- GET /v1/roles/layouts — the system-wide catalog. Despite the path this is
  not a per-role read, and it is the only source of `portletTitles` (the
  localized portlet names resolved server-side), so it drives every row and
  the Included Tools column.
- GET /v1/roles/{roleId}/layouts — direct grants. The backend resolves this
  as `from LayoutsRoles where role_id = ?` with no hierarchy walk, so
  effective grants are composed client-side by walking the ancestor chain —
  the same shape the Users tab already uses, reusing collectAncestorChain.
  Each row is tagged with the closest ancestor that grants it, which is what
  the Granted From chip names.
- POST /v1/roles/layouts — a full replace, not an append: the backend diffs
  the payload against the role's current grants and drops the difference. So
  one toggle syncs the whole grid in a single call, and the payload always
  carries the complete direct-grant set. Inherited grants are excluded on
  purpose — echoing one back would silently promote it to a direct grant, so
  inherited rows render checked but locked, revocable only on the ancestor
  named in their chip.

All three live in the shared DotRolesService: they are reads and writes of
the roles domain, and splitting them by consumer count or by destructiveness
put one domain in two files behind a rule invisible to anyone reading either.
DotRoleToolGroup moves to @dotcms/dotcms-models alongside DotRole; the row
projection (granted + grantedFrom) stays in the portlet, since it is
presentation.

Header count

`toolGroupCount` counts effectively-granted groups and is loaded on role
selection rather than when the tab opens — the header shows it on every tab,
so deferring it would leave it reading 0 until the admin clicked Tools.

Removals

The tools iframe component and its two JSPs (view_role_tools_wrapper.jsp,
view_role_tools_inc.jsp) are deleted rather than left behind. The shared
view_role_iframe_stubs_inc.jsp stays — Permissions still wraps an iframe —
and its comments are updated so they stop pointing at deleted files.

Tests: 11 suites / 122 passing. 36 projects linted, 35 tested, format:check
and the dotcms-ui production build clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Finishes what the earlier consolidation started. Splitting one domain across
two services by consumer count and by destructiveness put related endpoints
in different files behind a rule invisible to anyone reading either one. The
rule now is simply: roles endpoints live in the roles service.

Moved and renamed — the service already says "roles", so the methods stop
repeating it:

  searchRoles          -> searchTree   (search() is taken by _search)
  loadRoleMembers      -> getUsers
  createRole           -> create
  updateRole           -> update
  deleteRole           -> delete
  grantUserToRole      -> grantUser
  removeUsersFromRole  -> removeUsers
  reparentRole         -> reparent

The tool-group trio is normalized the same way now that every method is
role-scoped: getAllToolGroups() for the catalog, getToolGroups(roleId) and
saveToolGroups(roleId, ids) for the role's own.

loadRootRoles / loadRoleById were pure delegations to getRoots / getById and
are gone; the store calls the shared service directly. `get` and `search`
keep their legacy names — the workflow assign components inject this service
and those paths stay untouched.

DotRoleFormValue moves to @dotcms/dotcms-models (data-access cannot import
from a portlet) and the wire adapters move alongside the service, with their
spec, so the mapping logic keeps its coverage.

What stays behind

DotRolesPortletService is down to one method: searchUsers. /v1/users/filter
is a users endpoint, not a roles one, so it does not belong in DotRolesService
either. It stays until data-access has a shared users service to host it —
dot-users has one, but portlet-to-portlet imports are not allowed.

Tools tab: no flicker on toggle

Every checkbox click ran a reload that set status to LOADING, so the whole
table swapped for the skeleton. The toggle is now painted optimistically and
the post-save reconcile runs silently, leaving the table in its loaded state
throughout. A failed save rolls the patch back rather than leaving the grid
showing something the backend rejected.

Un-checking a group an ancestor also grants is the one case the optimistic
patch cannot resolve locally — a row only keeps its closest source — so the
silent reconcile restores the inherited chip a moment later.

Tree badge

Smaller person icon, larger count, and the secondary grey actually applies:
PrimeNG colors the node label with a more specific selector, so the badge was
inheriting its near-black.

Tests: 10 suites / 112 passing, including three that pin the no-flicker
behavior. 36 projects linted, 35 tested, format:check and the dotcms-ui
production build clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`text-xs` (12px) read too small next to the 14px count. `text-sm` puts the
icon back at the count's own size, and no scale step sits between them, so
the icon takes an explicit 13px — the same arbitrary-value-with-important
pattern other portlets already use for material symbols.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The Tools tab greyed its checkboxes out and showed the cannot-edit notice for
CMS Administrator, while the legacy portlet lets you change that role's tool
groups and save. The Beta was stricter than both the backend and the screen it
replaces.

Cause: `canModifyRole` correctly gates on `system` / `locked`, and that shape
was copied to the per-domain gates. But `RoleHelper` applies a different
contract to each:

  update / delete role   isSystem() || isLocked()
  grant / remove users   !isEditUsers()                 — nothing else
  save layouts           no check on the target role at all

`POST /v1/roles/layouts` only requires the CALLER to hold the CMS Admin role,
which surfaces as a 403. CMS Administrator is a system role with editLayouts
true, so the legacy grid — which reads `currentRole.editLayouts` alone — enables
it and this tab did not.

canEditRoleLayouts now gates on `editLayouts` alone. That flag is kept even
though the backend ignores it, because it is the contract the legacy screen
honours and dropping it would be a behaviour change of its own.

canEditRoleUsers had the same defect and is fixed with it: user grants on CMS
Administrator were blocked for the same invented reason. Not reported yet, but
the same bug.

canEditRolePermissions is deleted rather than fixed — it had no consumers, the
Permissions tab is still an iframe.

Also, from design review on the tree:

- shield -> shield_person for leaf roles, in the tree and the detail header.
- The user-count badge takes `text-gray-400`. It had `text-color-secondary`,
  which is a PrimeFlex class — PrimeFlex is not installed, so it resolved to
  nothing and the badge inherited the node label's near-black. The dead class
  and a comment blaming PrimeNG specificity are gone with it. 104 more
  occurrences of that class survive across 35 files; they need their own pass.
- The badge icon returns to text-sm.

Tests: 116 passing, four of them pinning the corrected gates — including that a
system + locked role permits user and tool edits but still refuses update and
delete.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@github-actions github-actions Bot added Area : Backend PR changes Java/Maven backend code Area : Frontend PR changes Angular/TypeScript frontend code labels Aug 28, 2026
@claude

claude Bot commented Aug 28, 2026

Copy link
Copy Markdown
Contributor

Claude finished @hmoreras's task in 4m 54s —— View job


Claude Code Review

Reviewed the full diff against origin/main: the shared DotRolesService consolidation, the store races/error-path fixes, the new Tools tab, the tree/hierarchy childCount handling, and the add/edit dialog rework. The race guards (selectedRoleId() === roleId at both ends of saveToolGroups/grant/remove), the failed-vs-empty distinction in loadMembers/loadToolGroups, the childCount propagation in appendChildToParent/removeNodeFromTree, and the #searchToken guard in the add dialog are all correct and well-tested. I verified there are no dangling references to the removed service methods/types and that every i18n key used in the changed templates exists in Language.properties.

One introduced regression worth addressing:

New Issues

  • 🟡 Medium: ChangeDetectionStrategy.OnPush was removed from six components that previously declared it — the components now fall back to default (zone) change detection:

    • dot-roles-page/dot-roles-page.component.ts:34
    • dot-roles-page/components/dot-roles-tree/dot-roles-tree.component.ts:56
    • dot-roles-page/components/dot-role-users-tab/dot-role-users-tab.component.ts:61
    • dot-roles-page/components/dot-roles-detail-header/dot-roles-detail-header.component.ts:19
    • dot-roles-add/dot-roles-add.component.ts:38
    • dot-roles-edit/dot-roles-edit.component.ts (present on origin/main, gone here)

    Plus the new dot-role-tools-tab.component.ts ships without it. These are fully signal-driven components (store signals + computed), which is exactly the case OnPush is meant for; the repo's Angular standards call for OnPush on new/edited components (progressive enhancement). Nothing in the PR body explains the removal, so it reads as an accidental strip during the rewrite rather than a deliberate choice. Non-blocking, but it's a real perf/convention regression across the portlet. If a specific component needed default CD (e.g. a PrimeNG internal-state mutation not triggering a re-check), keep that one and document why, and restore OnPush on the rest. Fix this →

Everything else in the diff checks out — no correctness, security, or replay-safety issues found in the changed code. The security note about the unauthenticated GET /api/v1/roles/layouts is correctly filed separately (#37259) and not introduced here.

· branch issue-36930-follow-up

Correctness

Two cross-role races, both reachable with ordinary navigation and normal
latency. `saveToolGroups` and `grantUserToRole`/`removeUsersFromRole` capture
the role id before their await and reconcile with it afterwards; a role switch
in between meant a late response repainted the new role's tab with the old
role's data. Worse for tool groups: the tab computes its next POST payload from
whatever is in `toolGroups`, so one role's grants could be written onto another.
Guarded at both ends — the reconcile only fires while the role is still
selected, and the rxMethod sinks refuse to write for a role that is no longer
selected (the caller-side guard also stops switchMap from cancelling the new
role's legitimate load). `selectRole` now clears `toolGroupsSaving` too, which
was locking every checkbox on the role being switched TO.

Per-ancestor `catchError` in `loadToolGroups` and `loadMembers` turned "could
not verify" into "not granted". A transient failure showed an inherited tool
group unchecked; an admin trusting the grid would grant it again, creating a
redundant direct grant off a masked network error. Failures are now tracked and
surface as an error state instead of a confident wrong answer.

Making `childCount` authoritative for leaf detection regressed local tree
mutations: `appendChildToParent` and `removeNodeFromTree` left the count stale,
so a parent that just gained its first child rendered as a childless leaf and
the new role was unreachable. Both keep it in sync now.

`POST /v1/roles` answers with `Role.toMap()`, not a `RoleView`, so it carries no
`childCount` — a freshly created role drew as a folder. It is normalised to 0,
which is true by construction. (`PUT` returns a RoleView and needed nothing.)

The tree's user-count badge never refreshed after a grant or revoke. It syncs
from the direct-grant count when members load — direct only, matching what the
backend puts in that field, and skipped when a check failed so a stale number is
not replaced by a wrong one.

`dot-users-list.store.spec.ts` still mocked `getUserRoles`, deleted from
`DotUsersService` in this PR, and never mocked `DotRolesService` — so the roles
column was exercised against the real root service and was effectively untested.

Add / Edit role dialogs

The required marker was hand-written text; it now uses the `dotFieldRequired`
directive, which is what makes it red. There was no validation message at all —
`dot-field-validation-message` is wired in. The dialog title moves from an h2 in
the body to the dialog header.

Parent is a `p-treeSelect` instead of a flat indented list: indentation alone
gets ambiguous past two levels. It starts collapsed, hydrates a branch on expand
(the backend sends two levels), and its filter runs the same deep search the
roles tree uses, so a role in an unloaded branch is findable.

Two PrimeNG behaviours had to be worked around, both from the same cause — the
component keeps state inside the node objects, and our options come from a
computed that hands it new ones. Expansion is mutated onto `node.expanded`, so
branches snapped shut the moment their children loaded; expanded keys are
tracked in a signal and re-applied. And `Tree.getRootNode()` returns its cached
`filteredNodes` once the client filter has run, ignoring `value`, so server
results never rendered until the filter is re-applied over the new options.

Design review

Empty states use the shared `DotEmptyContainerComponent` — the previous
hand-rolled dashed card exists nowhere else in the product. `shield` becomes
`shield_person`. The `+` on a tree row is primary with a pointer cursor. The
user-count badge takes a real grey: `text-color-secondary` is a PrimeFlex-era
class, PrimeFlex is not installed and tailwindcss-primeui does not provide it,
so it compiled to nothing and the badge inherited the label's near-black
(verified against the built CSS, not assumed). Granted From has a minimum width.
Untranslated portlet titles no longer print as raw i18n keys.

Note on standards: ANGULAR_STANDARDS asks for Signal Forms on new forms, but
`dotFieldRequired` injects `FormGroupDirective` and
`dot-field-validation-message` takes an `AbstractControl` — the standard
validation components are Reactive-Forms-bound. These dialogs stay Reactive.

Tests: 136 passing. The race and badge regressions were verified by neutering
each fix and confirming the new tests go red. 36 projects linted, 35 tested,
format:check and the dotcms-ui production build clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@hmoreras
hmoreras marked this pull request as ready for review August 28, 2026 16:41
hmoreras and others added 2 commits August 28, 2026 10:42
The previous commit claimed to guard both ends of the cross-role races. It
only guarded the sink. The reconcile dispatches in saveToolGroups,
grantUserToRole and removeUsersFromRole still fired with the role id they
captured before their await, and that incomplete fix introduced a worse bug
than the one it closed: a stale dispatch enters the SHARED rxMethod, whose
switchMap cancels the current role's in-flight load, and the sink guard then
discards the stale result — so nothing ever settles that role's status and its
tab sits on the loading skeleton indefinitely. Guarded at the caller now, with
the sink guard kept as defence in depth.

Two more cross-role leaks from the same review:

- `saveToolGroups`'s rollback restored its `previous` snapshot without checking
  identity, so a failed save on role A could overwrite role B's grid — and the
  next save would persist A's rows as B's.
- `toolGroupsSaving` was cleared unconditionally on completion, so a finishing
  save could unlock a grid whose own save was still running.

Silent reparent to root

The Edit dialog resolved the role's current parent to a tree node and set the
picker only `if (node)`. A role reached through the page's search can have its
parent chain missing from the cached tree, so the picker stayed empty and Save
sent `parentRoleId: null` — moving the role to root on a dialog the admin only
opened to rename it. The Add dialog already had this fallback; Edit now does
too.

Cycle exclusion bypassable through search

The Edit picker built its exclusion set from the cached tree but rendered from
`searchResults ?? cached`. Search results come from a different endpoint and
carry ancestor paths the lazy tree never loaded, so a real descendant could
appear as a selectable parent — defeating the guard precisely on the path most
likely to surface deep roles. Exclusion is now collected from both datasets.

Badge sync missed the rendered tree

While a search is active the tree renders `searchResults`, not `roles`. The
user-count patch only touched `roles`, so the fix silently did nothing on the
common path of finding a role by typing. It patches both now.

createRole's fallback was a no-op

The "parent not in the loaded tree" branch fetched the parent and called
`patchNodeChildren`, which rewrites a node it must first find — in exactly the
case this branch exists for, it matched nothing. The role was created and
selected in the detail pane, so it read as success while never appearing in the
tree. It reloads the roots instead: heavier than a scoped patch, but coherent.

Parent picker search

Added a staleness token so a slower earlier query cannot overwrite a newer
one's results — the debounce gates when a search starts, not what order
responses arrive, and these are plain promises with no switchMap to cancel the
loser. Backspacing below the 3-character threshold now also resets PrimeNG's
inner filter; reverting the options alone left the previous search's rows on
screen, since `Tree.getRootNode()` keeps serving its cached `filteredNodes`.

Tests

A mock seeded with `mockResolvedValue` (not `Once`) leaked its implementation
into every later test in the suite — `mockClear` resets history, not behaviour.
And the "leaves the badge alone on failure" test captured an unset baseline, so
it only proved we do not write `undefined` on a first failed load rather than
the case it names: resetting an already-good count from a partial answer. Both
verified by an independent test reviewer, which also confirmed the race and
badge regression tests do fail without their guards.

Lib cleanup

`standalone: true` and `changeDetection: ChangeDetectionStrategy.OnPush` are
removed from all 9 components in the portlet — both are Angular 22 defaults and
ANGULAR_STANDARDS asks for neither to be set. No component used
`ChangeDetectionStrategy.Eager`, which the same standard says to leave alone.

Tests: 136 passing. Lint, format:check and the dotcms-ui production build clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@hmoreras
hmoreras requested review from AP2300 and oidacra August 28, 2026 17:00
Four changes, all about the portlet feeling settled instead of twitchy.

Tool-group saves now raise the standard toast, debounced so ticking a
handful of boxes in a row yields one notification rather than a stack.

Creating a child under a collapsed parent reveals it: the tree expands
the whole ancestor chain (the parent may itself be collapsed) and fetches
the parent's children when they were never loaded — otherwise the branch
would hold only the new role and read as though its siblings had been
deleted. Selection was already handled by the store.

The checkbox blink after a tool-group save was PrimeNG's default
`rowTrackBy`, which is object identity. The reconcile rebuilds every row
object, so Angular tore down and re-created each <tr> and with it the
p-checkbox. Tracking by id lets it patch the rows in place.

The members table blinked for a different reason a level up: loadMembers
flipped to LOADING unconditionally, and the template renders the skeleton
on LOADING, so a post-grant refetch swapped the entire table out and back
— nothing rowTrackBy can reach, since it is a different @if branch. It
now takes the same `silent` flag loadToolGroups already had, used by both
the grant and the remove paths. Selecting a role stays non-silent: there
are no rows on screen yet, so the skeleton is right there.

Also gave the Granted From tag a fixed-height slot. It renders only on
granted rows, so without a reserved line box the row grew the instant a
checkbox was ticked.

Every fix has a regression test, each verified to go red when the fix is
reverted.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Area : Backend PR changes Java/Maven backend code Area : Frontend PR changes Angular/TypeScript frontend code

Projects

Status: No status

Development

Successfully merging this pull request may close these issues.

[Task] Roles and Tools: Angular implementation + backend wiring

1 participant