Skip to content

task(content-drive): add push publish actions and delete to folders, gate creation on permissions #35161 - #37146

Open
zJaaal wants to merge 28 commits into
mainfrom
issue-35161-content-drive-folder-push-history
Open

task(content-drive): add push publish actions and delete to folders, gate creation on permissions #35161#37146
zJaaal wants to merge 28 commits into
mainfrom
issue-35161-content-drive-folder-push-history

Conversation

@zJaaal

@zJaaal zJaaal commented Aug 20, 2026

Copy link
Copy Markdown
Member

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

  • Push History for folders — new html/portlet/ext/folders/push_history.jsp (22 lines) resolving the folder from folderIdentifier, checking EDIT_PERMISSIONS server-side, and including the shared edit_publishing_status_inc.jsp. Hosted by DotJspIframeDialogComponent (see the review round below).
  • Push Publish for folders and contentlets, spawning DotPushPublishDialogService — the app-wide dialog locales, plugins and experiments already drive for a single asset. No new dialog component.
  • Add to Bundle for folders. No new plumbing: the shell already renders the bundle dialog off the context menu's own target keyed on identifier.
  • Order: Folder Settings, Edit Permissions, Push Publish, Add to Bundle, Push History.

Action Center

  • Folders are no longer dropped from the selection. Support is 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. The Workflow Actions half is untouched — folders have neither a content type nor a scheme, so they never reach it.
  • Actions that do not apply drop out of the list rather than showing a count of zero.

Delete

Creation gating

  • New, Upload and the drop zone are gated on CAN_ADD_CHILDREN, site root included. All three create something in the browsed folder, and the server enforces that permission on it (FolderAPIImpl:673-676 for a folder, ESContentletAPIImpl:605-609 for the contentlet an upload creates), so previously the user picked a file or filled in a dialog and only then got refused.
  • One store computed feeds all three, so they cannot disagree about the same folder.

Shared

  • withPushPublishEnvironments resolves 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-257 resolves an unrecognized identifier to PusheableAsset.FOLDER and enforces PermissionAPI.PERMISSION_PUBLISH, reporting a denial as a per-asset entry in errorMessages rather 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_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 from the frontend alone. All three are wrong, each differently, and two of them convincingly:

Source Why it fails
GET /api/v1/permissions/user/{userId} Reports only the user's individual role (PermissionSaveHelper:357). On the demo data Chris holds three roles, and Publisher / Legal carries 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 / entry Carries addChildrenAllowed and matches expectations on the demo data, but resolves against the global SYSTEM_FOLDER singleton (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/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. +26 lines across 4 files, plus 5 auto-generated lines of openapi.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:

  • The two iframe dialogs are now one. diff confirmed DotPushHistoryIframeDialogComponent and DotPermissionsIframeDialogComponent were 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 into DotJspIframeDialogComponent, taking titleKey / emptyKey / testIdPrefix through the dialog config. Renamed rather than kept as DotPermissions* 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 with describe.each over both. Incidentally fixes the permissions dialog's hardcoded, untranslated title="Permissions".
  • Deleting a folder you are inside 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. #browsingInside compares with a trailing slash on both sides so /blog-archive/ is not read as inside /blog/.
  • The folder invariant is now held by the compiler. eligibleWhen and warnWhen take DotContentDriveItem; the contentlet-only predicates narrow with isFolder. Adding supportsFolders to an action whose predicate reads locked or contentType fails to build instead of reading undefined off a folder. The cast at the call site is gone.
  • A failed environments lookup is logged before the gate settles on false. UX unchanged — a broken endpoint just no longer reads as "no environment configured" to whoever is debugging it.
  • Tests that were asserting less than they appeared to: the delete-failure cases asserted only what did not happen, so removing the handle(error) call kept them green; deleteFolder had no error-path test, so the consumer's negative assertions rested on nothing; the environments lookup was asserted toHaveBeenCalled() 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 to executePushPublish.
  • Plus: SUCCESS_MESSAGE_LIFE on the success toast, the folders-ignored test 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-row supportsFolders lookup 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.

  • Dropping onto a folder in the sidebar tree bypassed the gate, for both of its outcomes — dropped files uploaded, dropped rows moved. Both now refuse with a toast naming the folder or the site. canAddChildrenTo extracts the rule so the New menu, Upload, the grid drop zone and the tree drop cannot disagree about one folder.
  • A newly created folder's parent came back unexpandable. loadFolders() was firing; the bug was in buildTreeFolderNodes, where isLeaf read folderHierarchyLevels.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-609 as the server enforcing 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.

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

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

Additional Info

Security notes

  • The new JSP checks EDIT_PERMISSIONS server-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.
  • The same-origin iframe guard is now a single shared predicate, 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.com and /<tab>/evil.example.com all 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.
  • Push Publish is offered disabled when no environment is reachable, and the reason sits in the label ("Push Publish (no environment)") rather than a tooltip. A disabled context menu item computes pointer-events: none, so no hover reaches it and no tooltip can fire; PrimeNG's ContextMenu also binds pTooltip from tooltipOptions alone, so a plain MenuItem.tooltip is accepted by the type and silently ignored on top of that. A label needs neither hover nor click. The command guards as well, since disabled only 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 (undefined reads 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-dropzone gained a disabled input that suppresses the overlay, the dragEnter output 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

  1. A column subset handed the checkbox column the leftover width. Measured in Chrome on the Action Center preview's 586px table: the authored percentages for title, live, 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 — the 3rem checkbox 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. rescaleToWidthBudget scales 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.
  2. Folder rows arrived with no inode. The drive-search view omits it, but the table keys rows on inode (language variants of a contentlet share an identifier, so it cannot key on that), leaving every folder row colliding on undefined. 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 asymmetry executeAddToBundle already 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 $bundleCollapsedCount exists 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 missing DotContentDriveItem import and toDistinctIdentifiers not widened), and because nx serve kept 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 under tsc -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: ui 1403, portlets-content-drive 1278, data-access 787, utils 108, plus content-drive/ui 53 and dot-publishing-queue 255 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_history empty state. Without popup=true the body renders visibility:hidden (that parameter is load-bearing, since these adapters never include bottom_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 and PushedAssetHistory already exist if this should later move to the Angular timeline.

Separate pre-existing observation. SELECT_ASSETS_BY_ASSET_ID (PushedAssetsFactory.java:29) is ORDER BY push_date with no direction, so every push-history view, Site Browser's own tab included, is oldest-first, and ContentPushHistoryPaginator ignores its orderBy/direction so page 1 of /api/v1/content/{id}/push/history returns the oldest pushes. Left alone: changing it is a behaviour change to a public API contract that PushedAssetsFactoryTest pins (: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, Published badge overflowing into FileAsset).

🤖 Generated with Claude Code

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>
@zJaaal zJaaal added the PR: docker image Build & push a per-PR test image to dotcms/dotcms-test label Aug 20, 2026
@github-actions github-actions Bot added Area : Backend PR changes Java/Maven backend code Area : Frontend PR changes Angular/TypeScript frontend code labels Aug 20, 2026
@claude

claude Bot commented Aug 20, 2026

Copy link
Copy Markdown
Contributor

Claude finished @zJaaal's task in 3m 33s —— View job


Code Review — Folder push-publish actions, delete, and creation gating

I reviewed the diff on this branch against main, focusing on the two most recent commits (the tree drop-gating + isLeaf fix and the denied-folder test) alongside the full set of new folder-action, permission-plumbing, and gating changes.

✅ No new issues found

The changes are internally consistent and the logic holds up under review. Highlights I verified:

  • tree-folder.utils.ts:138isLeaf fix. The old folderHierarchyLevels.length >= levelIndex + 1 was true for every on-path node, marking a folder a leaf as its children were attached and hiding the PrimeNG toggler. The new (folderHierarchyLevels[levelIndex + 1]?.length ?? 0) === 0 correctly reads the next level, so intermediate on-path nodes stay expandable and only a truly childless level marks a leaf. Correct.
  • functions.ts canAddChildrenTo / withSitePermissions.ts / dot-permissions.service.ts. The three-state (undefined/true/false) gating is coherent, and the deliberately different failure defaults are sound and documented: site-root creation fails open (canAddChildren !== false, softening a server-guarded affordance) while Push Publish fails closed. The reset-to-undefined before each site lookup, plus filtering SYSTEM_HOST before the reset, correctly prevents a stale answer from gating a new site.
  • push_history.jsp. Server-side EDIT_PERMISSIONS enforcement is real (not client-only); the try/catch collapses DotSecurityExceptionnull so an unreadable folder and a missing id render the same single message, disclosing nothing about which denial applied. folderIdentifier is passed to the API, not concatenated into SQL, and is never echoed to the page. Correct.
  • Backend permission plumbing (AssetPermissionHelper, AbstractAssetMetadata, AbstractAssetPermissionsView, PermissionResource). canAddChildren is resolved server-side via doesUserHavePermission(asset, PERMISSION_CAN_ADD_CHILDREN, user, false), threaded through the immutable builders, and the @Schema matches the return type. Conventions-compliant.
  • action-center.ts getQuickActions. The flatMap + supportsFolders scoping is correct: folder-capable actions see the whole selection and emit identifiers, contentlet-only actions see filtered contentlets and emit inodes, and an action with an empty scope returns [] (dropped, not shown with a 0 count). eligibleWhen/warnWhen take the union type so the contentlet predicates narrow internally with no cast.
  • #canDropInto drop gating in the shell component consistently gates both upload and move, with the site-root vs folder toast message chosen from the presence of permissions.

Previously-raised findings — resolved

All findings from earlier review rounds appear addressed in the current diff: the delete gate corrected to EDIT + EDIT_PERMISSIONS, rxMethod conversion, the iframe dialog merge, error logging in the environments lookup, the folders-limited-message test-id rename, orphaned-key deletion, and the isLeaf / drop-gate fixes in the latest commit.

…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>
@zJaaal zJaaal moved this to In Progress in dotCMS - Product Planning Aug 20, 2026
@zJaaal zJaaal changed the title task(content-drive): add push history to the folder context menu #35161 task(content-drive): add push actions for folders #35161 Aug 20, 2026
@github-actions

github-actions Bot commented Aug 20, 2026

Copy link
Copy Markdown
Contributor

🐳 PR Docker test image

Latest build for commit 05f0884 pushed to dotcms/dotcms-test:

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

zJaaal and others added 4 commits August 20, 2026 17:08
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>
@zJaaal zJaaal changed the title task(content-drive): add push actions for folders #35161 task(content-drive): add push publish actions to folders #35161 Aug 20, 2026
zJaaal and others added 2 commits August 20, 2026 19:04
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>
@zJaaal zJaaal changed the title task(content-drive): add push publish actions to folders #35161 task(content-drive): add push publish actions and delete to folders #35161 Aug 20, 2026
Comment thread core-web/libs/portlets/dot-content-drive/portlet/src/lib/utils/action-center.ts Outdated
@zJaaal
zJaaal requested a review from rjvelazco August 24, 2026 15:08

@rjvelazco rjvelazco left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Four follow-ups from a second pass. None are blockers; the two test ones close AC clauses that are currently unverified.

@rjvelazco rjvelazco left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread dotCMS/src/main/webapp/WEB-INF/messages/Language.properties Outdated
Comment thread dotCMS/src/main/webapp/html/portlet/ext/folders/push_history.jsp
…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>
@zJaaal zJaaal changed the title task(content-drive): add push publish actions and delete to folders #35161 task(content-drive): add push publish actions and delete to folders, gate creation on permissions #35161 Aug 24, 2026
zJaaal and others added 2 commits August 24, 2026 13:02
…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 rjvelazco left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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>
@claude

claude Bot commented Aug 24, 2026

Copy link
Copy Markdown
Contributor

test

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 PR: docker image Build & push a per-PR test image to dotcms/dotcms-test

Projects

Status: In Progress

Development

Successfully merging this pull request may close these issues.

[TASK] Content Drive: add Push publish actions and Delete to folders, and gate creation on permissions

4 participants