feat(dot-roles): Tools tab in Angular, roles service consolidation, and edit-gate fixes - #37260
Conversation
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>
|
Claude finished @hmoreras's task in 3m 1s —— View job Re-review — prior findings + fix commitsChecked the current head against the 9 prior findings and the three fix commits (
Resolved
Existing (non-blocking)
Fix commits — no new issues
No new correctness or security issues found in this pass. Findings 1–5 (the pre-merge blockers) are all resolved. |
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>
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>
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.
AP2300
left a comment
There was a problem hiding this comment.
Code review — 9 findings, ranked most-severe first. Correctness bugs first, cleanup/altitude at the end. The recurring theme is that the cross-role-race guard the PR added for saveToolGroups isn't lifted to a shared helper and reapplied uniformly across mutations.
Findings referenced against the current head (d2e386b).
1. updateRole / createRole skip the cross-role guard the PR added for saves
dot-roles.store.ts:837-840, :881-891
Both write selectedRole / selectedRoleId unconditionally after the await. saveToolGroups and the user grant/remove flows added selectedRoleId() === roleId guards for exactly this reason.
Scenario: admin edits role A and saves, then clicks role B before the PUT resolves. selectRole clears state and loads B. PUT for A resolves after: selectedRole is overwritten with A while B is highlighted; the header, canModifyRole computeds, and the Users/Tools tabs all now describe A. A subsequent save on that panel PUTs against role A.
Consider extracting a withSelectedRoleScope((roleId) => …) helper (or the switchMap-keyed-by-roleId pattern in a shared feature slice) so every mutation acquires the guard by construction.
2. collectAncestorChain fabricates a parent-less stub → silent under-reporting of inherited grants
dot-roles.tree-utils.ts:163-179
When the picked role isn't in the merged tree, findRoleInTree(...) ?? { id, name: id } produces a stub with no parent; the loop exits after zero iterations and the fan-out shrinks to a single direct-grants request.
Scenario: a search-selected role whose branch hasn't been merged into roles / searchResults, or a race with a roles: [] reset (post-createRole root reload). loadMembers / loadToolGroups fire only GET /v1/roles/{targetId}/{users,tools}, silently omitting every ancestor's grants. Users tab renders an incomplete member roster and the Tools tab misses inherited grants — admin trusts the UI and re-creates a redundant direct grant. No error surfaced.
If the caller doesn't know the parent, this should fail loudly (or fall back to a getById) rather than short-circuit.
3. createRole seeds selectedRole with the raw POST payload — edit-gate flags are absent
The comment on L799 says POST /v1/roles returns Role.toMap(), not a RoleView; the comment on L833 then claims the response is "already a hydrated RoleView" and seeds selectedRole directly. The type is asserted with a cast + childCount: 0 spread — TS doesn't catch it. system, locked, editUsers, editPermissions, editLayouts are absent, and downstream computeds fall back to permissive defaults (?? true) until the next page reload.
Scenario: admin creates a role whose contract would forbid user grants (editUsers === false per template). The just-created payload lacks the flag; the Users tab renders enabled; adds are either 403'd (bad UX) or, if the BE is lenient on fresh roles, silently promoted.
Two consistent options: (a) drop the "seed from POST" optimization and fire a follow-up GET; (b) have createRole echo a hydrated RoleView on the BE (worth a Java-side follow-up in either case, since PUT already does).
4. removeUsersFromRole runs the optimistic patchState before the cross-role guard
[dot-role-users-tab.component.ts grant/remove path → dot-roles.store.ts around L1006]
The guard only gates the follow-up loadMembers; the optimistic filter has already written into members, which by then may be a different role's slice.
Scenario: admin bulk-removes u1,u2 from role A, then clicks role B (inherits u1) before the DELETE resolves. selectRole flips the pane to B; the optimistic filter strips u1 from members — now B's slice — and the guarded reload skips because selectedRoleId !== A. B's member table wrongly shows u1 missing until the admin re-selects B.
Move the optimistic write behind the same guard, or key it against a captured roleId snapshot rather than the current members.
5. $searching never resets on stale-token drop or the <3-char short-circuit
dot-roles-add.component.ts:183-216, [dot-roles-edit.component.ts #runSearch]
$searching.set(true) is set right before await; on token !== #searchToken the function returns without $searching.set(false). Same shape in the edit dialog.
Scenario: admin types "abc" then a fourth char before the promise resolves; #searchToken bumps, old promise resolves into the guard-return branch, $searching stays true for the rest of the dialog session — the parent-picker looks permanently busy.
try/finally { $searching.set(false); } around the await would fix both.
6. Partial fan-out failure hides successful ancestor rows behind a generic ERROR
[dot-roles.store.ts around L397-L401 in loadMembers / loadToolGroups result mapping]
A single ancestor request failing flips membersStatus (and the tool-groups equivalent) to 'ERROR' even after the other ancestors' rows are already merged into state — the template hides the partial table behind a generic error banner while httpErrorManager fires a toast per failed request.
Scenario: one of five inherited role fetches returns a transient 5xx. Four ancestors' users are in members; template renders "Could not load" instead of them. Admin retries by re-selecting, fan-out repeats and can hit the same intermittent failure.
Introduce a 'PARTIAL' status (or a verified: boolean on the slice) so the table renders alongside an inline "some ancestors couldn't be verified" warning.
7. updateRole reparent branch mistakes a missing parent in the response for detach-to-root
nextParentId = updated.parent && updated.parent !== updated.id ? updated.parent : null. If the PUT response ever omits parent (defensive undefined, partial view, future serializer refactor), previousParentId === nextParentId is false and the code splices the node out of its parent and appends it as a new root. childCount on the true parent isn't decremented, so the tree also shows the wrong count until reload.
Contract-wise the BE encodes root as parent === role.id; parent === undefined isn't in that contract. If it arrives, fail closed (keep the node where it was) rather than silently reparenting.
8. _filter private-API workaround + treeSelect state duplicated across Add and Edit
dot-roles-add.component.ts:194,215, [dot-roles-edit.component.ts equivalent]
The PR description calls out this line as "the most upgrade-fragile line in this PR" — and it exists twice. The surrounding #expandedKeys / onNodeExpand / onNodeCollapse / #searchToken / #runSearch / #toTreeNodes triad is ~90% duplicated between the two dialogs.
Cost: the next PrimeNG bump that renames _filter or restructures treeViewChild breaks both dialogs; fixes will drift over time as one gets updates the other doesn't.
Extract a shared dot-role-parent-picker (or a directive that owns the workaround) so the fragile access lives behind one seam. The two dialogs then differ only in their initial value, submit call, and the descendant-exclude filter.
9. Tool-group toggle fires a full reconcile per click — debounce coalesces only the toast
[dot-role-tools-tab.component.ts onToggle → dot-roles.store.ts:693 saveToolGroups], :218 toolGroupsSaved$.pipe(debounceTime(...))
The 1.5s toolGroupsSaved$ debounce coalesces only the toast; each save still triggers a full catalog GET + N ancestor tool-group GETs. A burst of 5 checkbox clicks on a role with 3 ancestors is 5 POSTs + 5 catalog GETs + 15 ancestor GETs.
Coalesce the reconcile into the same debounce (queue the id-set of dirty groups, single reconcile after the burst). Under network jitter, out-of-order reconciles can also restamp state with a stale union — the guarded write only gates the last one to resolve, not the earlier stale ones.
Out-of-scope for this PR but worth capturing: dot-users-list.store.ts bulk-delete path has no aggregate toast when every deletion fails (only per-request errors). Behavior pre-dates this PR; noting here so it isn't lost.
Nice work overall — the consolidation into DotRolesService, the Tools-tab Angular migration, and the tree-node child-count / reparent handling read cleanly. Findings 1–4 are the ones I'd want addressed before merge; the rest can be follow-ups.
…chain under-report Review feedback on #37260. Lift the cross-role guard the PR added for `saveToolGroups` onto the mutations that were still missing it: - `updateRole` wrote `selectedRole` unconditionally after its await. The dialog is modal, but ESC / the X close it while the PUT is in flight, so a role switch afterwards left the header, `canModifyRole` and both tabs describing the role that was edited — and the next save on that pane would have targeted it. The tree patch stays unguarded: it is keyed by id. - `createRole` seeded the selection the same way. It now only takes the selection if the pane has not moved on. - `removeUsersFromRole` guarded its reload but not the optimistic prune, which reads `members` — by then possibly another role's slice. Both are behind the guard now. Fix the ancestor chain, which drives the per-ancestor fan-out for both effective membership and effective tool grants: - `collectAncestorChain` climbed by `parent` id, but `unwrapLegacySearchNode` keeps id/name/locked and drops `parent`. Any role opened from the search results therefore produced a one-element chain and silently lost every inherited member and tool group. It now walks the tree structure, which both trees carry. - A role missing from the tree got a fabricated parentless stub — indistinguishable from a root, so callers fanned out to direct grants only and rendered the result as complete. The chain is now empty and `loadMembers` / `loadToolGroups` surface it instead of fanning out. - `mergeTreesPreferParent` deduped roots preferring the copy with `parent`; that tie-break is dead under a structural walk, and dropping a root's other copy loses the branch only that copy hydrated. Renamed to `mergeTreesForLookup` and no longer deduped. Two more the review surfaced indirectly: - `createRole` left the previous role's `members` and `toolGroups` in state. The Users tab reloads off a `selectedRole` effect; the Tools tab has none, so it kept painting the old role's grants — Granted From chips included — under the new role's name. - `$searching` was set before the await and never cleared on the stale- token drop or the under-3-chars short-circuit, so the parent picker span forever once a query was superseded mid-request. Both dialogs. Each guard was verified by neutering it and confirming the new tests go red. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
One conflict, in `dot-users.service.ts`: this branch removed `getAllRoles` and its `expandRoleTree` / `expandDescendants` helpers when the roles surface was consolidated into `DotRolesService`, while main added the API token methods to the same region of the file. Resolved by keeping main's API token methods and dropping the roles walk. Nothing calls `getAllRoles` any more — this branch moved its last consumer onto the pure `dot-roles-hierarchy.utils.ts` helper — and the walk would no longer compile regardless: the `switchMap` / `forkJoin` / `of` / `catchError` imports and the `DotRoleView` / `DotRolesResponse` types it depends on were removed with it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
@AP2300 the mention options was fixed; except 7 and 9 since are in some way false positive |
…y-load gate
Reparenting a role from the Edit dialog repainted its children four times
and hid its grandchildren.
Both come from the same place: the reparent path was the only one that
took the moved node's `roleChildren` and `childCount` from the PUT
response.
`PUT /v1/roles/{roleId}` hydrates two levels at most, so everything below
the moved role's direct children is absent from it — taking that copy
drops every lazy-loaded descendant out of the tree. `patchNodeInPlace`
already keeps the node's existing children on the same-parent path for
exactly this reason; the reparent path now does the same.
That response is also wrong today. Verified against a running instance
with a role that has ONE child:
GET before → childCount: 1, children: [Child]
PUT move → childCount: 4, children: [Child, Child, Child, Child]
GET after → childCount: 1, children: [Child]
The persisted rows are correct — which is why a reload cleared it — so
this is a response-body defect in `RoleFactoryImpl`, filed separately.
Preferring the counts we already hold makes the UI right either way.
The second half is ours regardless of that fix: `#fetchedRoleIds` is
add-only, so once a branch lost the children it had already fetched,
`onNodeExpand` returned early forever and the branch stayed expanded and
empty until a full page reload. It now re-fetches when `childCount` says
children exist and state holds none, which also closes the known
follow-up where a failed lazy-load never retried.
Each fix verified by neutering it and confirming the new tests go red.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…d role lands in Two follow-ups from testing the previous commit against a live instance. The duplicate children came back. Preferring the children we already hold only helps when we hold some: a node whose branch was never expanded has `roleChildren: []`, so the response's copy was all there was and it went in untouched. That is the common case — `GET /v1/roles` hydrates two levels, so anything from the third level down starts empty. Neither `roleChildren` nor `childCount` is read raw from the response any more; both are folded through `dedupeRolesById` once, before either branch uses them. That also closes a case the first pass missed entirely: the same-parent path runs `patchNodeInPlace`, which preserves the node's existing children but takes every other field from the replacement, so the inflated `childCount` reached the tree even on a plain rename. Second, reparenting from the Edit dialog keeps the role selected but moves it under a parent that is usually collapsed, so the role the admin was just editing disappeared on save. The tree now opens the ancestor path of the selected role when that path changes. Keyed on the path rather than on the tree: `roles` is rewritten on every member load to refresh the user-count badge, and reacting to that would re-open branches the admin had just collapsed. Only ancestors are opened — expanding the role itself would unfold a branch nobody asked for. The backend defect behind the duplication is #37303. This makes the UI correct either way. Each fix verified by neutering it and confirming the new tests go red. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Proposed Changes
role-portlet-progress.mp4
Follow-up work on the Roles (Beta) Angular portlet.
Consume the backends that landed —
GET /v1/roles/{roleId}/users(#37070) replaces the previous pair of member-loading calls. The old id-based/rolehierarchyanduserrolesfallback returnedRoleobjects rather than users, which is why the Users tab rendered an empty EMAIL column; that column now populates.childCountanduserCount(#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}/userswould 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
RoleAjaxDWR) and belong to a Tools portlet of their own. Inherited grants render checked but locked; echoing one back into the full-replacePOSTwould 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 backendRoleView. Reads, writes and models now live in one place; method names drop the redundantrole(createRole→create).getandsearchkeep 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 indot-users, and it now prunes onchildCount— 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.
RoleHelperapplies a different contract per operation: update/delete checksisSystem() || isLocked(), user grants checkisEditUsers()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:
saveToolGroupsand the user grant/remove flows captured the role id before theirawaitand 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 fromtoolGroups, 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.catchErrorreturned 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.childCountregressions. 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. AndPOST /v1/rolesanswers withRole.toMap(), which carries nochildCount, so a freshly created role drew as a folder.dot-users-list.store.spec.tsstill mocked a method deleted in this PR and never mockedDotRolesService, so the roles column ran against the real root service and was effectively untested.Add / Edit role dialogs
Required markers use the
dotFieldRequireddirective (which is what makes them red) instead of hand-written text, anddot-field-validation-messageis 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
computedthat hands it new ones. Expansion is mutated ontonode.expanded, so branches snapped shut the moment their children loaded. AndTree.getRootNode()returns its cachedfilteredNodesonce the client filter has run, ignoringvalue, 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.shield→shield_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-secondaryis a PrimeFlex-era class — PrimeFlex is not installed andtailwindcss-primeuidoes 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
Additional Info
Verification — 36 projects linted, 35 tested,
nx format:checkclean, and thedotcms-uiproduction build green. Thedot-rolessuite is at 136 tests.Security note. While tracing the tool-group endpoints,
GET /api/v1/roles/layoutsturned out to have no authentication gate — the only one of 16 endpoints inRoleResourcewithout aWebResource.InitBuilderblock. Confirmed at runtime: it returns200with the full tool-group catalog to an unauthenticated caller, while three sibling endpoints return401under 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.mdasks for Signal Forms on new forms, but the standard validation components are Reactive-Forms-bound —dotFieldRequiredinjectsFormGroupDirectiveanddot-field-validation-messagetakes anAbstractControl. These dialogs stay on Reactive Forms; adopting Signal Forms would mean dropping both.Follow-ups not included here
loadRootRoles(true)→falseis now possible thanks tochildCount, and would lighten the initial payload considerably on installs with many roles. It changes lazy-load behaviour, so it deserves measuring on its own.onNodeExpandmarks a node fetched before the load resolves, so a failed lazy-load never retries.text-color-secondarysweep described above.🤖 Generated with Claude Code
This PR fixes: #36930
This PR fixes: #36930