task(content-drive): add push publish actions and delete to folders, gate creation on permissions #35161 - #37146
task(content-drive): add push publish actions and delete to folders, gate creation on permissions #35161#37146zJaaal wants to merge 28 commits into
Conversation
Site Browser is being deprecated in favor of Content Drive, and its folder push publish history had no equivalent there. That history lived only as a Dojo tab inside edit_folder.jsp, which takes its folder from a Struts request attribute rather than a URL parameter, so it cannot be iframed on its own. The push history table itself is already a shared include that every push-history view wraps in a small per-asset-type adapter. Folders never got one, so add it, plus a context menu item that opens it in a dialog, following the Edit Permissions item already in the same menu. - html/portlet/ext/folders/push_history.jsp resolves the folder from folderIdentifier, checks EDIT_PERMISSIONS server-side to match the gate the legacy tab used, and includes edit_publishing_status_inc.jsp - DotPushHistoryIframeDialogComponent hosts it, keeping the same-origin URL guard and taking its iframe title from i18n rather than hardcoded English - the menu item shares the existing EDIT_PERMISSIONS block, matching edit_folder.jsp where both tabs sat behind one check No new Java, no new endpoint. The view inherits the shared include's lack of pagination, which is how content types, templates, and containers behave too. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Claude finished @zJaaal's task in 3m 33s —— View job Code Review — Folder push-publish actions, delete, and creation gatingI reviewed the diff on this branch against ✅ No new issues foundThe changes are internally consistent and the logic holds up under review. Highlights I verified:
Previously-raised findings — resolvedAll findings from earlier review rounds appear addressed in the current diff: the delete gate corrected to |
…edicate Review on #37146 flagged that the same-origin URL guard now lives in two dialog components, so a future hardening fix has to be applied twice or drift. It already needed one. The string check accepted paths that resolve cross-origin. Browsers normalize backslashes into the authority position and strip tab, newline and carriage return before parsing, so `/\evil.com`, `/\/evil.com` and `/<tab>/evil.com` all reach an external host despite not starting with `//`. isSameOriginRelativeUrl delegates resolution to the URL parser and compares the resulting origin, which catches every such form rather than enumerating prefixes. It is a type guard, so callers holding an optional url narrow it on the way through. Both dialog components now share it. Not reachable through any current call site: all three build their URL from a hardcoded /html/... prefix with only an id interpolated, so this is hardening, not a live hole. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
🐳 PR Docker test imageLatest build for commit docker pull dotcms/dotcms-test:pr-37146-issue-35161-content-drive-folder-push-history
docker pull dotcms/dotcms-test:pr-37146-issue-35161-content-drive-folder-push-history_05f0884 |
The Action Center owned this lookup, but the folder context menu is about to gate a Push Publish item on the same answer. Two components calling getEnvironments() independently would mean two copies of the three-state handling to keep in step, which is the duplication pattern review just flagged on the iframe guard. withPushPublishEnvironments resolves it once per portlet load, following withSidebar in owning its own onInit. The three states are preserved exactly: undefined means the lookup has not landed and reads as disabled, an empty list and a failed lookup both settle on false. Failing closed is deliberate, since a push with nowhere to go fails at the servlet with a message the user cannot act on. The Action Center now reads the store signal and drops its own service injection, loader and ngOnInit call. Its environments tests move with the lookup: the mapping from a service answer onto the flag is asserted in the new slice spec, and the dialog's own tests drive the store signal and assert only what the dialog does given an answer. One test is renamed to say so, and a flag nothing set any more is removed. The service mock stays, since it still backs the environment selector embedded in the push publish step. No user-visible change. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… context menu Content Drive had no way to push publish anything from the context menu, and no way to add a folder to a bundle. Both work on the backend already: an unrecognized identifier resolves to PusheableAsset.FOLDER in PublisherAPIImpl and PUBLISH is enforced there, with a denial reported as a per-asset error rather than an exception. Folder menu order is now Folder Settings, Edit Permissions, Push Publish, Add to Bundle, Push History. Push History moves to its own EDIT_PERMISSIONS block after the push group rather than sitting beside Permissions, since it is read-only audit data and reads last. Contentlets get Push Publish too, ahead of the Add to Bundle item they already had, matching the folder order. Push Publish spawns DotPushPublishDialogService, the app-wide dialog locales, plugins and experiments already drive for a single asset, so there is no new dialog component. Add to Bundle needs no new plumbing at all: the shell already renders the bundle dialog off the context menu's own target keyed on identifier, which is the one id a folder from the sidebar tree always carries. With no reachable environment the item is disabled with a tooltip rather than hidden, matching the Action Center: nothing is missing from dotCMS, something is missing from the configuration, and the fix is an administrator's. The command guards too, since disabled only suppresses the click. No new i18n keys — contenttypes.content.push_publish, contenttypes.content.add_to_bundle and content-drive.action-center.no-environments all already exist. Four existing contentlet assertions counted or indexed the menu and shift by the added item. Counts are updated; the Add to Bundle trigger now finds its item by label, since an index there would silently point at Push Publish instead. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…h selections Folders were dropped from every Action Center selection, so a folder could not be bundled or pushed from the table even though the backend resolves a folder identifier to PusheableAsset.FOLDER and enforces PUBLISH there. Folder support is now per action, from the registry: supportsFolders sits beside requiresEnvironments and is set on Add to Bundle and Push Publish only. Lock, Unlock and Refresh still see contentlets only, and the Workflow Actions half is untouched, since folders have neither a content type nor a scheme and never reach it. Actions that do not apply drop out of the list rather than showing a count of zero: a folder-only selection is not "no eligible rows", it is an action that does not apply to what is selected. eligibleInodes keeps its meaning for the actions that fire on inodes, which is what pins the version and therefore the step, so one contentlet on two steps still contributes two entries. The two folder-capable actions put identifiers there instead, because that is what they fire on anyway, documented on the field the same way executeAddToBundle already documents it. The preview reads the matching key per action; matching a folder on inode dropped it from the preview of the action it was about to be fired on, which showed as an empty preview. The toolbar now opens the Action Center for a folder-only selection, the header counts folders, and the notice says folders are limited to those two actions rather than claiming they are ignored. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…r width The folder list view renders a subset of its columns in the Action Center preview, and the table came out visibly wrong: the name sat far right of its heading, and the Status badge overflowed into Type. Measured in Chrome on the preview's 586px table. The authored percentages for title, live and contentType total 53%, not the 96% the full set carries, and `table-layout: fixed` has to put the unclaimed 47% somewhere. Chrome gives it to the leading column, which is the 3rem checkbox one, so that rendered 275px instead of 48 and pushed everything right, leaving Status 59px for a badge that does not fit. The comment here previously assumed the leftover lands on the title column; it does not. rescaleToWidthBudget scales a rendered subset's percentages back up to the same total the full set carries, preserving the proportions between the columns shown. The checkbox column measures 42px after it and Status 103px. The full set is handed back untouched, since its percentages already add up. Also backfills the inode on folder rows at the drive-search boundary. The view returns folders without one, but the table keys rows on inode (language variants of a contentlet share an identifier, so it cannot key on that), which left every folder row colliding on undefined. dotCMS keeps a folder's inode equal to its identifier, so this only fills a gap; a folder that carries one is left alone. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two type errors that jest never saw. ts-jest runs with isolatedModules, so it transpiles without typechecking: 1278 tests passed while `nx serve` failed to rebuild the component and kept serving the last good bundle. The result looked like a stale cache — the utils file's new behavior was live while the component's was not — and presented as an empty Add to Bundle preview with stale counts. - the component used DotContentDriveItem without importing it - toDistinctIdentifiers still took DotCMSContentlet[] while being handed the widened type, which the plan called for and the previous commit missed Verified in the browser after this: Add to Bundle on a folder plus a contentlet reaches the preview through the real Continue button, lists both rows, and the subset column widths hold (checkbox 42px, Status 103px). Both additions are strict-mode clean, per #35932. Every strict error reported across the touched files sits on a pre-existing line. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Content Drive could not delete a folder. The endpoint for a single folder already shipped, POST /api/v1/assets/folders/_delete (WebAssetResource:314), so this needs no backend work and does not wait on the bulk endpoints on #37062. Multi-select delete does, and stays there. Gated on EDIT, because that is what FolderAPIImpl.delete enforces (:438), and ordered last in the menu since it is the one entry that destroys something. Confirms first through the app-wide DotAlertConfirmService, already hosted globally in app.component.html. The server delete is recursive, so the copy says the folder's contents go with it rather than just naming the folder. The path is built from the browsed site's hostname rather than the folder's hostId: the drive search is scoped to //<hostname><path>, so every folder listed is on the site being browsed. Without a resolved site the call is skipped instead of posting //undefined. Refetches the sidebar tree as well as the grid. They reload separately — reloadContentDrive only calls loadItems — so the tree kept listing a folder that no longer existed until the next navigation. Existing folder assertions that counted the menu shift by one, since Delete is offered on any folder with EDIT. The shell spec gains the confirm-service provider, which the context menu now injects. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
rjvelazco
left a comment
There was a problem hiding this comment.
Four follow-ups from a second pass. None are blockers; the two test ones close AC clauses that are currently unverified.
rjvelazco
left a comment
There was a problem hiding this comment.
Minor / nit pass — everything still open from my earlier read that isn't already covered by a thread. None of these are blockers.
For the record, two things I checked and am not raising: the preview component's input types (fixed in 99f82da) and the environments lookup now firing on every portlet load rather than on dialog open — the docblock already justifies that trade explicitly.
…luded
Content Drive offered New, Upload and the drop zone regardless of whether the
user could add children to the folder they were browsing. All three create
something in that folder, and the server enforces CAN_ADD_CHILDREN on it
(FolderAPIImpl:673-676 for a folder, ESContentletAPIImpl:605-609 for the
contentlet an upload creates), so the user picked a file or filled in a dialog
and only then got refused.
Folders already carry their own CAN_ADD_CHILDREN with the tree. The site root
did not: its parent is the host, not a folder, and nothing reported on it.
Three candidate sources looked like they could answer that without touching the
backend. None of them can:
- GET /api/v1/permissions/user/{userId} reports only the user's *individual*
role (PermissionSaveHelper:357). A grant held through a group role reads as
no grant, which would hide the buttons from users who can in fact use them.
- byPath's `/` entry looks right, and matches by luck on the demo data, but
resolves against the global SYSTEM_FOLDER singleton
(FolderHelper#findSubfoldersUnderHost). That row is one per installation
with host_inode = SYSTEM_HOST and no permission rows, so it answers
identically no matter which site is open.
- /api/v1/folder/search returns the root's children, never the root.
So the host needs its own answer. AssetPermissionHelper#getAssetMetadata already
resolved canEditPermissions and canEdit for the calling user through
permissionAPI, which honours every role the user holds; this adds canAddChildren
beside them and maps it through both view builders. No new endpoint and no new
access path: GET /api/v1/permissions/{assetId} was already reachable by a
non-admin for a host.
The gate itself is one store computed, read by all three affordances so they
cannot disagree about the same folder. Both unknowns resolve to allowed — a
lookup still in flight, and an instance too old to report the field — because
starting disabled would flicker the buttons for the common case, and the server
refuses the write either way.
The tooltip hangs off a wrapper rather than the button: a disabled PrimeNG button
computes pointer-events: none, so a tooltip bound to it can never fire.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…over canAddChildren
The drop zone previously went fully inert without CAN_ADD_CHILDREN: no overlay,
no dragEnter, no upload. A drop target that stops responding reads as broken
rather than as forbidden, and left the user with nothing to act on.
It now still opens on drag and carries the reason in place of the usual prompt,
with a lock icon rather than the upload one. The upload is still refused, since
the server would refuse it anyway (ESContentletAPIImpl:605-609) and failing
after the transfer starts is worse than declining up front.
`disabledMessage` is a separate input so `libs/ui` stays domain-agnostic: the
host owns the wording for why its own uploads are refused. An empty message
falls back to the normal prompt, so a host that disables without explaining
still renders something coherent.
Two of this component's tests asserted the inert behaviour and now assert the
explaining one; that is a deliberate change to what correct means here, not a
test bent to fit the code.
Also adds the backend coverage that was missing. `getAssetPermissions` had no
test at all, so `canAddChildren` shipped unverified:
- admin resolves true without an explicit grant
- READ alone resolves false, the case the site-root gate exists for
- an explicit CAN_ADD_CHILDREN grant resolves true, which pins the field to
that bit rather than to "has any permission"
- a grant held only through a shared group role resolves true
That last one is the regression guard. GET /v1/permissions/user/{userId} reports
only the user's individual role and answers false for exactly that user, which
is why it could not back this gate; if this endpoint is ever rewired to a
role-scoped lookup, the gate would start hiding creation actions from users
entitled to them, and this test is what catches it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The folder tree intermittently showed the wrong folders on a cold load and corrected itself on reload. `loadFolders` fired a request and `.subscribe()`d, with no cancellation between calls, while two triggers call it on a cold load: this feature's own `onInit` and the sidebar component's `currentSite` effect. Both writes landed, so whichever request *resolved* last won regardless of which *started* last. A slower earlier response then overwrote a newer complete one, and the tree kept the losing result until something reloaded it. It is now an `rxMethod` with `switchMap`, so the newest call is the only one that can still write. `catchError` moved inside the inner pipe: left outside it would end the `rxMethod` subscription, making the first failed load the last one the store ever ran. `take(1)` is gone, since `rxMethod` owns the subscription lifetime and `take(1)` there defeats the cancellation this fix depends on. `sidebarLoading` is now set inside `switchMap` rather than before it, so a cancelled load cannot leave the tree stuck in its loading state. The regression test drives the exact interleaving: a first load left pending, a second started and resolved, then the first answering last. It fails on the old implementation with the stale folder winning. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…edback Review pass on #37146 from @oidacra and @rjvelazco. **One dialog instead of two.** `diff` on DotPermissionsIframeDialogComponent and DotPushHistoryIframeDialogComponent showed them identical apart from an interface name, a title, a test id and an empty-state key. The second existed because the first was named for one caller and so did not look reusable — so the merged DotJspIframeDialogComponent is named for the job (hosting a legacy JSP in a dialog), and takes titleKey / emptyKey / testIdPrefix through the dialog config. Every screen dotCMS has not yet rebuilt in Angular is reached this way, so the next one should add a call site rather than a third copy. Renaming rather than keeping the "Permissions" name was deliberate: a component called Permissions that renders push history is exactly the stale name that makes the next person write a fourth copy instead of touching it. Three call sites updated (edit-content, dot-categories, dot-content-drive). Incidentally fixes the permissions dialog's hardcoded, untranslated `title="Permissions"` — it now resolves the `Permissions` key that already exists. **Deleting a folder you are inside now moves to the root.** The sidebar tree serves the same menu, so the deleted folder can be an ancestor of the browsed one; reloading the current path fetched a path that no longer existed, leaving an empty grid and a breadcrumb pointing inside a deleted folder. **Type system holds the folder invariant, not a comment.** `eligibleWhen` and `warnWhen` now take `DotContentDriveItem`, and the contentlet-only predicates narrow with `isFolder`. Adding `supportsFolders` to an action whose predicate reads `locked` or `contentType` now fails to build instead of silently reading `undefined` off a folder. The cast at the call site is gone. **A failed environments lookup is logged** before collapsing to `false`. The UX is unchanged — still disabled, still no toast — but a broken endpoint no longer reads as "no environment configured" with nothing anywhere for support to go on. Also: assert the delete failure reaches `DotHttpErrorManagerService` (dropping that call kept the suite green); an error-path test for `deleteFolder`; a Push Publish folder-in-payload test mirroring the Add to Bundle one; prove the environments lookup runs once rather than at-least-once; `SUCCESS_MESSAGE_LIFE` on the success toast; hoist the per-row `supportsFolders` lookup; drop the unused input-state constraint from both store features; rename the `folders-ignored` test id to match the copy and delete the key it stranded; document that `rescaleToWidthBudget` assumes percentage widths; drop a duplicate grid test whose comment asserted the belief this PR disproved; assert the contentlet menu order as one ordered list; and render a neutral message in `push_history.jsp` instead of a blank frame on every denied path. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Per @rjvelazco's review. `executeAddToBundle` and `executePushPublish` receive `string[]`, so by that layer a folder identifier and a contentlet identifier are the same type with the same handling — which is why I initially argued a folder-specific partial-failure case asserted nothing the neighbouring `should split failures out of the total` did not already cover. Adding it anyway, because the objection was about redundancy and the value is traceability: "a folder the user lacks PUBLISH on is reported as a failure, not silently dropped" is an acceptance criterion, and it was previously inferable only by reading one component test (the folder reaches the payload) together with one store test (a counted error becomes failCount) and joining them yourself. One test per folder-capable action now states it directly, with a folder identifier and the servlet's real error message. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
rjvelazco
left a comment
There was a problem hiding this comment.
LGTM! Personal comments and Claude comments resolved
…older's parent Two issues found in QA, plus a correction to a claim I had been repeating. **Dropping on a tree folder bypassed the permission gate.** A drag onto a tree node is a third route into a folder, alongside the New menu and the grid drop zone, and it was ungated for both of its outcomes: dropping files uploaded, and dropping rows moved. Both now refuse with a toast naming the folder or the site. **A newly created folder's parent came back unexpandable.** `buildTreeFolderNodes` computed `isLeaf` as `folderHierarchyLevels.length >= levelIndex + 1`, which is true for *every* on-path node — so a folder was marked a leaf in the same breath as its children were attached to it. PrimeNG hides the toggler for a leaf, so the new folder was in the model but unreachable, which read as the tree not reloading. A node is now a leaf only when the level below it is absent or empty. **Correcting the rationale.** I had cited `ESContentletAPIImpl:605-609` as proof the server enforces CAN_ADD_CHILDREN on upload. That check is real but sits inside `move`, not the create path. What the server actually enforces on a folder is: creating a folder (`FolderAPIImpl:673`) and moving a contentlet (`ESContentletAPIImpl:607`). The contentlet checkin path does **not** check it, so an upload is not refused server-side — a long-standing gap left alone here. That makes the upload gate a consistency measure rather than a preview of a refusal, which is a weaker justification than the one I gave, and the comments now say so. It is still worth having: without it, uploading into a folder quietly allows what creating a folder in the same place forbids. `canAddChildrenTo` extracts the rule the New menu, Upload, the grid drop zone and the tree drop now share, so four surfaces cannot disagree about one folder. Copy drops the "Add Children" wording: it is the internal permission name, not what the permissions UI calls it, and it means nothing to a customer. Two of the new tests were written, observed to pass, and rewritten because they passed for the wrong reason — the upload assertions targeted a service the shell never calls directly, and the target folder had no `defaultBaseType`, so no upload fired either way. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
test |
Proposed Changes
Closes #35161. Adds the folder push publish family to Content Drive, across both the context menu and the Action Center, plus two fixes found on the way.
Context menu
html/portlet/ext/folders/push_history.jsp(22 lines) resolving the folder fromfolderIdentifier, checkingEDIT_PERMISSIONSserver-side, and including the sharededit_publishing_status_inc.jsp. Hosted byDotJspIframeDialogComponent(see the review round below).DotPushPublishDialogService— the app-wide dialog locales, plugins and experiments already drive for a single asset. No new dialog component.identifier.Folder Settings,Edit Permissions,Push Publish,Add to Bundle,Push History.Action Center
supportsFolderssits besiderequiresEnvironmentsand is set on Add to Bundle and Push Publish only.Delete
EDIT(whatFolderAPIImpl.deleteenforces at:438), confirming first through the app-wideDotAlertConfirmService. Uses the already-shippedPOST /api/v1/assets/folders/_delete, so it needs no backend work and does not wait on the bulk endpoints on Async bulk folder copy and delete via the job queue #37062. Multi-select delete does, and stays on Content Drive: wire folder move, copy and bulk delete #37063.Creation gating
CAN_ADD_CHILDREN, site root included. All three create something in the browsed folder, and the server enforces that permission on it (FolderAPIImpl:673-676for a folder,ESContentletAPIImpl:605-609for the contentlet an upload creates), so previously the user picked a file or filled in a dialog and only then got refused.Shared
withPushPublishEnvironmentsresolves the reachable environments once per portlet load, so both surfaces gate on one signal instead of each running its own lookup.The push publish actions need no backend
PublisherAPIImpl.java:251-257resolves an unrecognized identifier toPusheableAsset.FOLDERand enforcesPermissionAPI.PERMISSION_PUBLISH, reporting a denial as a per-asset entry inerrorMessagesrather than throwing, which the existing error path already surfaces. Add to Bundle and Push Publish share that path, which is why folder ids already work for both.The creation gate does need one field
Folders already carry their own
CAN_ADD_CHILDRENwith the tree. The site root did not: its parent is the host, not a folder, and nothing reported on it.Three candidate sources looked like they could answer that from the frontend alone. All three are wrong, each differently, and two of them convincingly:
GET /api/v1/permissions/user/{userId}PermissionSaveHelper:357). On the demo data Chris holds three roles, andPublisher / Legalcarries its own grants on 5 assets this never returns. A grant held through a group role would read as no grant — hiding the buttons from users who can use them, the worst failure mode for a gate.byPath's/entryaddChildrenAllowedand matches expectations on the demo data, but resolves against the globalSYSTEM_FOLDERsingleton (FolderHelper#findSubfoldersUnderHost). That is one row per installation,host_inode = SYSTEM_HOST, zero permission rows — so it answers identically no matter which site is open, and a grant on the site would not move it. The demo match is a coincidence.GET /api/v1/folder/searchSo the host needs its own answer.
AssetPermissionHelper#getAssetMetadataalready resolvedcanEditPermissionsandcanEditfor the calling user throughpermissionAPI, which honours every role the user holds; this addscanAddChildrenbeside them and maps it through both view builders. +26 lines across 4 files, plus 5 auto-generated lines ofopenapi.yaml.No new endpoint and no new access path:
GET /api/v1/permissions/{assetId}was already reachable by a non-admin for a host, verified with a limited user against the running instance.Review round
Addressed 25 of the 27 threads from @oidacra and @rjvelazco. The substantive ones:
diffconfirmedDotPushHistoryIframeDialogComponentandDotPermissionsIframeDialogComponentwere identical apart from an interface name, a title, a test id and an empty-state key — the second existed because the first was named for one caller and so did not look reusable. Merged intoDotJspIframeDialogComponent, takingtitleKey/emptyKey/testIdPrefixthrough the dialog config. Renamed rather than kept asDotPermissions*on purpose: a component called Permissions that renders push history is the stale name that produces a fourth copy. Three call sites updated (edit-content,dot-categories,dot-content-drive); the merged spec is parameterised withdescribe.eachover both. Incidentally fixes the permissions dialog's hardcoded, untranslatedtitle="Permissions".#browsingInsidecompares with a trailing slash on both sides so/blog-archive/is not read as inside/blog/.eligibleWhenandwarnWhentakeDotContentDriveItem; the contentlet-only predicates narrow withisFolder. AddingsupportsFoldersto an action whose predicate readslockedorcontentTypefails to build instead of readingundefinedoff a folder. The cast at the call site is gone.false. UX unchanged — a broken endpoint just no longer reads as "no environment configured" to whoever is debugging it.handle(error)call kept them green;deleteFolderhad no error-path test, so the consumer's negative assertions rested on nothing; the environments lookup was assertedtoHaveBeenCalled()when consolidating it to once is the whole point of the feature; and Push Publish had no folder-in-payload coverage at all, so nothing proved a folder survived toexecutePushPublish.SUCCESS_MESSAGE_LIFEon the success toast, thefolders-ignoredtest id renamed and the key it stranded deleted,rescaleToWidthBudget's percentage assumption documented, a duplicate grid test dropped whose comment asserted the belief this PR disproved, the contentlet menu order asserted as one ordered list, the per-rowsupportsFolderslookup hoisted, and the unused input-state constraint dropped from both store features.Two threads deliberately left open: one asks @rjvelazco whether he wants a store-level partial-failure test that would differ from the existing one only by a string literal (by that layer a folder identifier is a contentlet identifier); the other waits on a browser check of the JSP's denied-path render, which I could not exercise over curl.
QA round
Two issues found testing on a build with the backend field, plus a correction.
canAddChildrenToextracts the rule so the New menu, Upload, the grid drop zone and the tree drop cannot disagree about one folder.loadFolders()was firing; the bug was inbuildTreeFolderNodes, whereisLeafreadfolderHierarchyLevels.length >= levelIndex + 1— true for every on-path node, so a folder was marked a leaf in the same breath as its children were attached to it. PrimeNG hides the toggler for a leaf, so the new folder was in the model but unreachable, which read as the tree not reloading.Correction to this description and to four code comments. Earlier revisions cited
ESContentletAPIImpl:605-609as the server enforcing CAN_ADD_CHILDREN on upload. That check is real but sits insidemove, not the create path. What the server actually enforces on a folder is creating a folder (FolderAPIImpl:673) and moving a contentlet (ESContentletAPIImpl:607); the contentlet checkin path does not check it, so an upload is not refused server-side.That makes the upload gate a consistency measure rather than a preview of a refusal — a weaker justification than the one originally given, and the comments now say so. It is still worth having: without it, uploading into a folder quietly allows what creating a folder in the same place forbids. The server-side gap is long-standing and deliberately not touched here; changing contentlet checkin permissions belongs in its own ticket with its own QA.
Copy drops the "Add Children" wording throughout — it is the internal permission name, not what the permissions UI calls it, and it means nothing to a customer.
Checklist
Additional Info
Security notes
EDIT_PERMISSIONSserver-side, not just at the menu level, so it does not render for someone who types the URL directly. This matters because the shared include exposes a Delete Push History button whose endpoint,BundleResource.deletePushHistory(:304), only requires a backend user with no per-asset check. That gap is pre-existing and shared by every push-history view; it is not introduced here, and it is the reason for the tighter gate. Worth its own ticket.isSameOriginRelativeUrl, and it fixes a real bypass. The previous string check only rejected a leading//, but browsers normalise backslashes into the authority position and strip tab, newline and carriage return before parsing, so/\evil.example.com,/\/evil.example.comand/<tab>/evil.example.comall resolve to an external host while passing it. The predicate delegates to the URL parser and compares the resolved origin. Not reachable through any current call site, since all three build their URL from a hardcoded/html/...prefix with only an id interpolated, so this is hardening rather than a live hole.pointer-events: none, so no hover reaches it and no tooltip can fire; PrimeNG's ContextMenu also bindspTooltipfromtooltipOptionsalone, so a plainMenuItem.tooltipis accepted by the type and silently ignored on top of that. A label needs neither hover nor click. The command guards as well, sincedisabledonly suppresses the click.On the creation gate's defaults. Both unknowns resolve to allowed: a lookup still in flight, and an instance too old to report the field. Denying on either would strip the buttons from every user on an older instance and flicker them off-then-on for the common case, and the server refuses the write regardless — the gate softens an affordance, it does not protect anything. The push publish gate makes the opposite call (
undefinedreads as disabled) because offering a push with nowhere to send it fails later and far less legibly; the two are deliberately different and each says why in place.The drop zone refuses the gesture rather than swallowing it.
dot-upload-dropzonegained adisabledinput that suppresses the overlay, thedragEnteroutput and the upload. The drag itself is left to the browser rather than cancelled, so the OS shows its own "cannot drop" cursor instead of the zone accepting a file and going quiet.The New/Upload tooltip hangs off a wrapper, not the button. Same PrimeNG constraint as the Push Publish menu item: a disabled button computes
pointer-events: none, so a tooltip bound to it can never fire. There is a test asserting the wrapper contains the button, so a later refactor that moves the binding back onto the button goes red.Two bugs found while building this, both fixed here
title, live, contentTypetotal 53%, not the 96% the full set carries, andtable-layout: fixedhas to put the unclaimed 47% somewhere. Chrome gives it to the leading column — the3remcheckbox one — which rendered 275px instead of 48, pushing the name far right of its heading and leaving Status 59px for a badge that does not fit, so it overflowed into Type.rescaleToWidthBudgetscales a subset back to the full set's total, preserving proportions; the checkbox column then measures 42px and Status 103px. The comment there previously assumed the leftover lands on the title column — it does not.inode. The drive-search view omits it, but the table keys rows oninode(language variants of a contentlet share an identifier, so it cannot key on that), leaving every folder row colliding onundefined. Backfilled at the service boundary; dotCMS keeps a folder's inode equal to its identifier, and a folder that already carries one is left alone.On
eligibleInodes. It keeps its meaning for the actions that fire on inodes, which is what pins the version and therefore the step, so one contentlet on two steps still contributes two entries. The two folder-capable actions put identifiers there instead, because that is what they fire on anyway — the same asymmetryexecuteAddToBundlealready documents. Correction to an earlier version of this description, which claimed the switch removed a latent divergence where two versions of one contentlet would count 2 while the payload sent 1. It does not, and there was nothing to remove.map()preserves length, so the badge counts selected rows either way, and that is deliberate: the Add to Bundle registry entry reads "Coverage is the whole selection, minus the identifier collapse the configuration step explains", and$bundleCollapsedCountexists to disclose the absorbed rows on that step. De-duplicating the badge would zero that disclosure out. No behaviour change here, only the key the ids are drawn from.Typechecked, not just tested. ts-jest runs with
isolatedModules, so it transpiles without typechecking — a green suite is not proof the app builds. Two type errors slipped through that way (a missingDotContentDriveItemimport andtoDistinctIdentifiersnot widened), and becausenx servekept serving the last good bundle they presented as an empty Add to Bundle preview rather than as a build failure. Every touched lib is now clean undertsc -p <lib>/tsconfig.lib.json --noEmit.Strict-mode clean, per #35932: every strict error reported across the touched files sits on a pre-existing line, none on the new code.
Built test-first throughout. Each unit went red before green, and no test was edited to force a pass. Where an existing assertion had to change, it was because the correct behaviour changed — the contentlet menu's index-based order assertions shifted by the added item, the Action Center's folder-exclusion counts now include folders, and the grid's "no rescaling" assertion encoded an assumption the measurement disproved. Each is called out in its commit. Full runs:
ui1403,portlets-content-drive1278,data-access787,utils108, pluscontent-drive/ui53 anddot-publishing-queue255 as the other consumers of the shared grid.Verified against a running instance. The JSP returns HTTP 200 with the heading, all four columns, a correctly disabled Delete button and the
publisher_status_no_push_historyempty state. Withoutpopup=truethe body rendersvisibility:hidden(that parameter is load-bearing, since these adapters never includebottom_inc.jsp), and missing, empty or bogus identifiers render no table and no error page. The column-width and folder-inode findings above were both measured in the browser rather than reasoned about.Known limitation, inherited not introduced. The push-history include calls the un-paginated
getPushedAssets(assetId)then does three lookups per row, so a folder pushed hundreds of times renders every row with an N+1 — exactly how content types, templates and containers behave today. The paginated overload andPushedAssetHistoryalready exist if this should later move to the Angular timeline.Separate pre-existing observation.
SELECT_ASSETS_BY_ASSET_ID(PushedAssetsFactory.java:29) isORDER BY push_datewith no direction, so every push-history view, Site Browser's own tab included, is oldest-first, andContentPushHistoryPaginatorignores itsorderBy/directionso page 1 of/api/v1/content/{id}/push/historyreturns the oldest pushes. Left alone: changing it is a behaviour change to a public API contract thatPushedAssetsFactoryTestpins (:245,:258), so it belongs in its own ticket. The reverse-chronological AC was dropped from #35161 for the same reason.Screenshots
Action Center preview, before and after the column rescale — the before is the reported defect (name pushed right of its heading,
Publishedbadge overflowing intoFileAsset).🤖 Generated with Claude Code