From 8edf244b8752d2c0ace2a62f1f72f04b7fd17ad7 Mon Sep 17 00:00:00 2001 From: Jalinson Diaz Date: Wed, 9 Sep 2026 20:46:22 -0300 Subject: [PATCH 01/44] docs(content-drive): spec for #37426 browse scopes for All, site root and System Host Content Drive has one browse scope today: the whole current site at any depth, with System Host mixed in. Selecting the site and browsing the site root are the same request, and System Host cannot be reached on its own. Specifies three scopes picked from the sidebar. All (the whole site, content only, read-only) sits above the site hierarchy, whose site row now means the site root. System Host sits below it, listing shared content on its own and accepting content dropped onto it. The "Show Shared Assets" chip becomes "Show System Host" and applies only to All. Callers that ask for no scope keep today's behavior, which is what leaves the Asset Picker and the other consumers of the shared listing untouched. Co-Authored-By: Claude Opus 5 --- .../37426-content-drive-browse-scopes/spec.md | 163 ++++++++++++++++++ 1 file changed, 163 insertions(+) create mode 100644 specs/37426-content-drive-browse-scopes/spec.md diff --git a/specs/37426-content-drive-browse-scopes/spec.md b/specs/37426-content-drive-browse-scopes/spec.md new file mode 100644 index 000000000000..02ed99c5670f --- /dev/null +++ b/specs/37426-content-drive-browse-scopes/spec.md @@ -0,0 +1,163 @@ +# Feature Specification: Content Drive browse scopes + +**Feature Branch**: `37426-content-drive-browse-scopes` + +**Created**: 2026-09-09 + +**Status**: Draft + +**Type**: New Feature + +**Input**: GitHub issue [#37426](https://github.com/dotCMS/core/issues/37426) — "[TASK] Content Drive: browse scopes for All, site root and System Host" + +## User Scenarios & Testing *(mandatory)* + +Content Drive can express only one browse scope today. Selecting a site lists everything on it, at every depth, with System Host content mixed in. There is no way to ask for the items that sit **at** the site root, and no way to reach System Host content on its own. This feature splits that single scope into three the user picks from the sidebar. + +### User Story 1 - Browse the site root, and browse the whole site, as separate things (Priority: P1) + +An editor opens Content Drive and wants to see what actually lives at the top of the site, not a flat list of every asset in every folder. Selecting the site in the hierarchy shows the site root: the items sitting there plus the site's top-level folders. When they do want the flat everything-on-this-site view, they select **All** at the top of the sidebar. + +**Why this priority**: This is the defect at the heart of the request. "Browse the site" and "browse the site root" are the same request today, so one of the two views simply does not exist. Everything else in this feature builds on the sidebar having distinct, selectable scopes. + +**Independent Test**: On a site with content at the root and content nested in folders, select the site row and confirm only root-level items and top-level folders are listed; select All and confirm the nested content appears and no folders do. Delivers the missing root view without any System Host work. + +**Acceptance Scenarios**: + +1. **Given** a site with an asset at its root and another asset inside a folder, **When** the user selects the site row in the hierarchy, **Then** the listing shows the root asset and the site's top-level folders, and does not show the asset that lives inside a folder. +2. **Given** the same site, **When** the user selects **All**, **Then** the listing shows both assets and shows no folders at all. +3. **Given** the user has selected **All**, **When** they select a folder in the hierarchy, **Then** the listing shows that folder's contents exactly as it does today. +4. **Given** the user has selected the site row, **When** they look at the listing, **Then** no System Host content appears in it regardless of any other setting. +5. **Given** any of the three sidebar entries is selected, **When** the user selects a different one, **Then** the previous selection is cleared, so exactly one entry is ever active. +6. **Given** the user has selected **All**, **When** they look for the ways to add content, **Then** uploading and creating are visibly unavailable, and dragging content over the listing offers no drop target, because **All** spans the whole site and names no single place to put anything. +7. **Given** the user is on a later page of **All**, **When** they select a different sidebar entry, **Then** the listing starts again at its first page with no items still selected. + +--- + +### User Story 2 - See System Host content on its own (Priority: P2) + +A user needs to find or manage assets shared across every site. Today those assets can only be seen mixed into a site's listing. Selecting **System Host** at the bottom of the sidebar lists System Host content and nothing else. + +**Why this priority**: It is the second capability that does not exist today, and it is what makes the "Show System Host" toggle honest: shared content becomes reachable on its own instead of only ever appearing as an overlay on a site. + +**Independent Test**: With shared content published to System Host and other content on a regular site, select System Host and confirm only the shared content is listed and no folders appear. + +**Acceptance Scenarios**: + +1. **Given** content exists on System Host and on the current site, **When** the user selects **System Host**, **Then** only the System Host content is listed. +2. **Given** the user has selected **System Host**, **When** they look at the listing, **Then** no folders are offered, because System Host has none. +3. **Given** the user has selected **All** with "Show System Host" on, **When** they look at the listing, **Then** System Host content appears alongside the current site's content. +4. **Given** the user has selected **All** with "Show System Host" off, **When** they look at the listing, **Then** no System Host content appears. +5. **Given** the user has selected the site row or a folder, **When** they look at the filter bar, **Then** the "Show System Host" control is not offered, because it cannot apply there. + +--- + +### User Story 3 - Move content to System Host by dropping it there (Priority: P3) + +Having selected some content, a user drags it onto the **System Host** entry to share it across every site, the same gesture they already use to move content into a folder. + +**Why this priority**: System Host is a real destination, so making it a drop target completes the interaction. It is separable from browsing: the scopes are useful before drag and drop is wired up. + +**Independent Test**: Select an item on a site, drag it onto the System Host entry, and confirm it afterwards appears under System Host and no longer under the site. + +**Acceptance Scenarios**: + +1. **Given** the user has selected content on a site, **When** they drop it onto the **System Host** entry, **Then** the content is moved to System Host and the listing reflects the move. +2. **Given** the user is dragging content, **When** they drag it over the **All** entry, **Then** it is not offered as a drop target and nothing is moved. +3. **Given** the user is dragging content, **When** they drag it over the site row or a folder, **Then** it behaves exactly as it does today. +4. **Given** the user lacks permission to add content to System Host, **When** they drag content over the **System Host** entry, **Then** it is not offered as a drop target. + +--- + +### Edge Cases + +- **A site with nothing at its root.** Selecting the site row shows the top-level folders and no content, or the empty state if the site has no folders either. It must not silently fall back to the everything view. +- **A search combined with a scope.** A text search narrows within the selected scope; it never widens it. Searching while the site row is selected must not start returning content from inside folders, and searching while System Host is selected must not start returning site content. +- **Restoring a shared link.** A link that carries a scope reopens on that scope. A link saved before this feature carries no scope and reopens on the view it produced before, so old links do not silently change meaning. +- **Switching sites while System Host is selected.** System Host is not part of any site, so the selection survives the switch and the listing is unchanged. +- **Deep link to a folder.** A URL pointing at a folder still selects that folder in the hierarchy, not All. +- **A user without read access to System Host content.** Permission filtering applies to every scope, so the System Host entry can legitimately produce an empty listing for such a user. +- **A user who may browse System Host but not add to it.** The System Host entry lists content but refuses uploads, creation and drops, the same way a folder the user cannot add to already behaves. +- **Switching scope mid-page.** A user on page 4 of All who selects System Host lands on the first page of System Host, with nothing carried over from the previous selection. + +## Requirements *(mandatory)* + +### Functional Requirements + +#### Sidebar structure + +- **FR-001**: The sidebar MUST offer three kinds of selection: an **All** entry at the top, the **site hierarchy** (the site row and its folders), and a **System Host** entry at the bottom. +- **FR-002**: **All** and **System Host** MUST be presented as plain sidebar sections, not as nodes of the site hierarchy: they have no expansion control, no children, and none of the behaviors that belong to folders. +- **FR-003**: Exactly one entry MUST be selected at any time; selecting one clears the previous selection. +- **FR-004**: The sidebar MUST NOT show item counts next to any entry. +- **FR-005**: Both sections MUST be reachable and selectable by keyboard, alongside the hierarchy they sit around. + +#### What each scope lists + +- **FR-006**: Selecting **All** MUST list the current site's content at any depth, and MUST NOT list folders. +- **FR-007**: Selecting the **site row** MUST list only the items that sit at the site root, including the site's top-level folders. +- **FR-008**: Selecting the **site row** MUST NOT include System Host content, under any setting. +- **FR-009**: Selecting a **folder** MUST list its contents exactly as it does today. +- **FR-010**: Selecting **System Host** MUST list System Host content only, and MUST NOT list folders. +- **FR-011**: Every scope MUST continue to respect the requesting user's read permissions. +- **FR-012**: A given selection MUST return the same items whether or not a text search or a field filter is active. Filtering narrows a scope; it never changes which scope was asked for. + +#### Creating, uploading and moving + +- **FR-013**: **All** MUST be a read-only view. Uploading, creating content or folders, and dropping content are all unavailable while it is selected, and the affordances MUST be visibly unavailable rather than offered and then refused. +- **FR-014**: The **site row**, a **folder**, and **System Host** MUST accept new content: uploads, creation, and content dropped onto them. For the site row and folders this is exactly today's behavior. +- **FR-015**: Dropping content onto the **System Host** entry MUST move it to System Host. +- **FR-016**: While **System Host** is selected, the permission check that gates creating and uploading MUST be evaluated against System Host itself, never against whichever site is selected in the site switcher. + +#### The System Host toggle + +- **FR-017**: The existing "Show Shared Assets" control MUST be renamed **"Show System Host"**. Only the label changes; links already in circulation that carry the control's current value MUST keep restoring correctly. +- **FR-018**: The control MUST be offered only while **All** is selected. +- **FR-019**: The control MUST retain its value while another scope is selected, so returning to **All** restores the user's previous choice rather than resetting it. +- **FR-020**: With the control on, **All** MUST include System Host content alongside the site's; with it off, **All** MUST exclude it. + +#### Persistence + +- **FR-021**: The selected scope MUST survive a reload, a browser back or forward, and a shared link, the way the other filters already do. +- **FR-022**: Changing the scope MUST return the listing to its first page and clear the current item selection, since neither carries any meaning across scopes. + +#### Not breaking what exists + +- **FR-023**: A content listing requested without a scope MUST behave as it does today, so other surfaces that share this listing (notably the Asset Picker) are unaffected by this feature. +- **FR-024**: No consumer of the shared content-listing service other than Content Drive may change behavior. Only Content Drive's own requests carry a scope; every other caller MUST keep producing exactly the results it produces today. + +### Key Entities + +- **Browse scope**: Which slice of content the listing is being asked for. One of: the whole current site at any depth, the current site's root only, or System Host only. +- **Site root**: The level of a site that is not inside any folder. Home to both root-level content and the site's top-level folders. +- **System Host**: The site-independent container for content shared across every site. It holds no folders, and site content cannot live inside it. + +## Success Criteria *(mandatory)* + +### Measurable Outcomes + +- **SC-001**: A user can list the items sitting at a site's root without any content from inside that site's folders appearing, which is not possible today. +- **SC-002**: A user can list System Host content on its own without any site's content appearing, which is not possible today. +- **SC-003**: In 100% of listings produced by the site-root and System Host scopes, every returned item belongs to the scope that was asked for. +- **SC-004**: A given scope returns the same set of items with a search active as without one, minus only the items the search legitimately excludes. +- **SC-005**: Opening a shared Content Drive link reproduces the scope the sender was viewing, every time. +- **SC-006**: Content is moved to System Host in a single drag, with no dialog and no intermediate step. +- **SC-007**: Surfaces other than Content Drive that browse content list exactly what they listed before this change, and a listing asked for without a scope returns exactly what it returns today. +- **SC-008**: No content can be created, uploaded, or moved from the All view; every route that would place content somewhere is unavailable there. + +## Legacy Considerations *(dotCMS-specific — mandatory)* + +- **Existing behavior touched**: The Content Drive listing and the underlying content-browsing service it shares with the Asset Picker, which is long-standing code that predates Content Drive. The meaning of "the site is selected" changes deliberately: it becomes the site root rather than the whole site, with the whole-site view moving to its own **All** entry. That is a visible behavior change for existing Content Drive users and is the point of the feature. +- **Backward-compatibility expectations**: **Preserving existing behavior is a hard constraint, not a preference.** The content-listing service Content Drive uses is shared with the assets API, the older file browser and its deprecated tree endpoint, the legacy admin browser, a Velocity viewtool, and two internal callers. None of them will ask for a scope, so whatever a scope-less request means today it must mean afterwards, byte for byte, including how System Host content is treated. Shared Content Drive links created before this change must keep restoring, including the value of the toggle being renamed. No deprecation of existing admin workflows is intended. +- **A rename that lands on two surfaces**: the "Show Shared Assets" label is a single shared translation used by both the Content Drive toolbar and the Asset Picker toolbar. Renaming it to "Show System Host" renames the chip in the Asset Picker as well. That is consistent terminology rather than a regression, but it is a visible change on a surface this feature does not otherwise touch, and it should be an accepted decision rather than a surprise. +- **Known related decisions**: The listing has two internal query paths that can disagree about whether System Host content is included; they must agree before any scope can be trusted, which is why the spec requires a scope to return the same items with or without a search rather than leaving it as an implementation concern. Moving content and browsing content also address System Host by different means, so support for one does not imply support for the other. Issue #37166 is related: it touches how Content Drive reports operations and surfaced this while examining what a move actually changes in the listing. The plan phase will formally consult `dotCMS/platform-adrs`. + +## Assumptions + +- **The default scope on a cold load is All**, which reproduces what a user sees today when Content Drive opens on a site. Landing on the site root instead would be a different first impression than the current product gives, so the conservative choice is assumed here and is worth an explicit confirmation before this spec is approved. +- **System Host accepts everything a folder accepts**: moved content, uploaded files, and newly created content. It is a real place to put things, unlike **All**, which spans the whole site and names no single destination. +- **Selecting System Host survives a site switch**, because System Host belongs to no site and the listing would not change. +- **The site root scope shows the site's top-level folders.** They sit at the root, so they are part of what is "at" the root. This means the site-root and All scopes differ in their content, not in their folders, since All shows no folders at all. +- **The Move dialog is not being changed to match the drop.** Its destination picker filters System Host out of the site list whenever a folder destination is required, which is the mode the dialog runs in, so dragging onto System Host will be the only route to that destination. Whether the dialog should offer it too is a product decision, deliberately left outside this feature. +- **"Children of the site root" is deliberately not a scope.** System Host has no folders and site content cannot live under System Host, so the site hierarchy already covers browsing below the root. +- **Item counts beside the sidebar entries are prototype-only** and are not part of this feature. From 907c1d8389ad499391279f3ab59a24939abb29aa Mon Sep 17 00:00:00 2001 From: Jalinson Diaz Date: Wed, 9 Sep 2026 20:53:18 -0300 Subject: [PATCH 02/44] docs(content-drive): settle what the drive opens on, for #37426 All is the default only on a cold open. A URL that names a scope opens on that scope; one that names none opens the folder it points at, or All when it points nowhere. That keeps links made before this feature meaning what they meant, and it makes the scope behave like every other filter rather than like a preference the URL cannot override. Co-Authored-By: Claude Opus 5 --- specs/37426-content-drive-browse-scopes/spec.md | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/specs/37426-content-drive-browse-scopes/spec.md b/specs/37426-content-drive-browse-scopes/spec.md index 02ed99c5670f..8b105aa7f1d8 100644 --- a/specs/37426-content-drive-browse-scopes/spec.md +++ b/specs/37426-content-drive-browse-scopes/spec.md @@ -75,7 +75,7 @@ Having selected some content, a user drags it onto the **System Host** entry to - **A search combined with a scope.** A text search narrows within the selected scope; it never widens it. Searching while the site row is selected must not start returning content from inside folders, and searching while System Host is selected must not start returning site content. - **Restoring a shared link.** A link that carries a scope reopens on that scope. A link saved before this feature carries no scope and reopens on the view it produced before, so old links do not silently change meaning. - **Switching sites while System Host is selected.** System Host is not part of any site, so the selection survives the switch and the listing is unchanged. -- **Deep link to a folder.** A URL pointing at a folder still selects that folder in the hierarchy, not All. +- **A URL that points at a folder and also names a scope.** The scope wins: a link naming System Host opens System Host even if it still carries the folder the sender happened to be in beforehand. - **A user without read access to System Host content.** Permission filtering applies to every scope, so the System Host entry can legitimately produce an empty listing for such a user. - **A user who may browse System Host but not add to it.** The System Host entry lists content but refuses uploads, creation and drops, the same way a folder the user cannot add to already behaves. - **Switching scope mid-page.** A user on page 4 of All who selects System Host lands on the first page of System Host, with nothing carried over from the previous selection. @@ -118,13 +118,15 @@ Having selected some content, a user drags it onto the **System Host** entry to #### Persistence -- **FR-021**: The selected scope MUST survive a reload, a browser back or forward, and a shared link, the way the other filters already do. -- **FR-022**: Changing the scope MUST return the listing to its first page and clear the current item selection, since neither carries any meaning across scopes. +- **FR-021**: The selected scope MUST travel in the URL alongside the other filters, so a reload, a browser back or forward, and a shared link all reopen the scope the sender was viewing. +- **FR-022**: A URL that names a scope MUST open on that scope. When that scope is All or System Host, a location carried elsewhere in the URL does not change what is listed, because neither scope points at a place in the hierarchy. +- **FR-023**: A URL that names no scope MUST open on the folder it points at, if it points at one, and on All otherwise. This is what keeps links made before this feature meaning what they meant: a link to a folder still opens that folder, and a link with no location still lists the whole site. +- **FR-024**: Changing the scope MUST return the listing to its first page and clear the current item selection, since neither carries any meaning across scopes. #### Not breaking what exists -- **FR-023**: A content listing requested without a scope MUST behave as it does today, so other surfaces that share this listing (notably the Asset Picker) are unaffected by this feature. -- **FR-024**: No consumer of the shared content-listing service other than Content Drive may change behavior. Only Content Drive's own requests carry a scope; every other caller MUST keep producing exactly the results it produces today. +- **FR-025**: A content listing requested without a scope MUST behave as it does today, so other surfaces that share this listing (notably the Asset Picker) are unaffected by this feature. +- **FR-026**: No consumer of the shared content-listing service other than Content Drive may change behavior. Only Content Drive's own requests carry a scope; every other caller MUST keep producing exactly the results it produces today. ### Key Entities @@ -154,7 +156,6 @@ Having selected some content, a user drags it onto the **System Host** entry to ## Assumptions -- **The default scope on a cold load is All**, which reproduces what a user sees today when Content Drive opens on a site. Landing on the site root instead would be a different first impression than the current product gives, so the conservative choice is assumed here and is worth an explicit confirmation before this spec is approved. - **System Host accepts everything a folder accepts**: moved content, uploaded files, and newly created content. It is a real place to put things, unlike **All**, which spans the whole site and names no single destination. - **Selecting System Host survives a site switch**, because System Host belongs to no site and the listing would not change. - **The site root scope shows the site's top-level folders.** They sit at the root, so they are part of what is "at" the root. This means the site-root and All scopes differ in their content, not in their folders, since All shows no folders at all. From 6b40cd126a7c44d841e2b79c1cb8a92c7a2dcc2e Mon Sep 17 00:00:00 2001 From: Jalinson Diaz Date: Fri, 11 Sep 2026 11:51:39 -0300 Subject: [PATCH 03/44] docs(content-drive): answer the spec review, for #37426 One URL value says where the drive is browsing, rather than a scope beside a path that could disagree with it: absent means All, `/` means the site root, a deeper path means that folder, and a reserved word means System Host. Real folder paths all begin with `/`, so a reserved word can never collide with one, and links made before this feature keep meaning what they meant. The search rule was worded backwards. It said a selection returns the same items with or without a search, which reads as "search does nothing" and contradicts the success criterion that already allows for what a search excludes. It now says filtering may only remove items from the scope, never add items from outside it, whichever internal search path serves the request, and both paths are exercised for the site root and System Host rather than only the one that runs by default. Saying All and System Host have none of the behaviors of folders was too broad, since System Host does take content. Narrowed to structure. Backward compatibility is no longer one blanket claim over eight consumers: the shared seam they all reach gets a test, and each consumer is recorded as covered by it or checked by inspection. Co-Authored-By: Claude Opus 5 --- .../37426-content-drive-browse-scopes/spec.md | 26 +++++++++++-------- 1 file changed, 15 insertions(+), 11 deletions(-) diff --git a/specs/37426-content-drive-browse-scopes/spec.md b/specs/37426-content-drive-browse-scopes/spec.md index 8b105aa7f1d8..69fe983a3cf0 100644 --- a/specs/37426-content-drive-browse-scopes/spec.md +++ b/specs/37426-content-drive-browse-scopes/spec.md @@ -31,6 +31,7 @@ An editor opens Content Drive and wants to see what actually lives at the top of 5. **Given** any of the three sidebar entries is selected, **When** the user selects a different one, **Then** the previous selection is cleared, so exactly one entry is ever active. 6. **Given** the user has selected **All**, **When** they look for the ways to add content, **Then** uploading and creating are visibly unavailable, and dragging content over the listing offers no drop target, because **All** spans the whole site and names no single place to put anything. 7. **Given** the user is on a later page of **All**, **When** they select a different sidebar entry, **Then** the listing starts again at its first page with no items still selected. +8. **Given** the site row is selected and a search is running, **When** the search is served by either of the product's two internal search paths, **Then** both return the same items, and neither admits content from inside a folder or from System Host. --- @@ -49,6 +50,7 @@ A user needs to find or manage assets shared across every site. Today those asse 3. **Given** the user has selected **All** with "Show System Host" on, **When** they look at the listing, **Then** System Host content appears alongside the current site's content. 4. **Given** the user has selected **All** with "Show System Host" off, **When** they look at the listing, **Then** no System Host content appears. 5. **Given** the user has selected the site row or a folder, **When** they look at the filter bar, **Then** the "Show System Host" control is not offered, because it cannot apply there. +6. **Given** **System Host** is selected and a search is running, **When** the search is served by either of the product's two internal search paths, **Then** both return the same items, and neither admits content belonging to a site. --- @@ -75,7 +77,7 @@ Having selected some content, a user drags it onto the **System Host** entry to - **A search combined with a scope.** A text search narrows within the selected scope; it never widens it. Searching while the site row is selected must not start returning content from inside folders, and searching while System Host is selected must not start returning site content. - **Restoring a shared link.** A link that carries a scope reopens on that scope. A link saved before this feature carries no scope and reopens on the view it produced before, so old links do not silently change meaning. - **Switching sites while System Host is selected.** System Host is not part of any site, so the selection survives the switch and the listing is unchanged. -- **A URL that points at a folder and also names a scope.** The scope wins: a link naming System Host opens System Host even if it still carries the folder the sender happened to be in beforehand. +- **A URL whose location is a reserved word rather than a path.** It selects that scope. The two cannot be confused, because every real folder path begins with `/` and no reserved word does, so no site can ever own a folder that collides with one. - **A user without read access to System Host content.** Permission filtering applies to every scope, so the System Host entry can legitimately produce an empty listing for such a user. - **A user who may browse System Host but not add to it.** The System Host entry lists content but refuses uploads, creation and drops, the same way a folder the user cannot add to already behaves. - **Switching scope mid-page.** A user on page 4 of All who selects System Host lands on the first page of System Host, with nothing carried over from the previous selection. @@ -87,7 +89,7 @@ Having selected some content, a user drags it onto the **System Host** entry to #### Sidebar structure - **FR-001**: The sidebar MUST offer three kinds of selection: an **All** entry at the top, the **site hierarchy** (the site row and its folders), and a **System Host** entry at the bottom. -- **FR-002**: **All** and **System Host** MUST be presented as plain sidebar sections, not as nodes of the site hierarchy: they have no expansion control, no children, and none of the behaviors that belong to folders. +- **FR-002**: **All** and **System Host** MUST be presented as plain sidebar sections, not as nodes of the site hierarchy: no expansion control, no children, and nothing beneath them to navigate into. This is about structure only. It does not stop System Host accepting content, which the rules below require of it. - **FR-003**: Exactly one entry MUST be selected at any time; selecting one clears the previous selection. - **FR-004**: The sidebar MUST NOT show item counts next to any entry. - **FR-005**: Both sections MUST be reachable and selectable by keyboard, alongside the hierarchy they sit around. @@ -100,7 +102,7 @@ Having selected some content, a user drags it onto the **System Host** entry to - **FR-009**: Selecting a **folder** MUST list its contents exactly as it does today. - **FR-010**: Selecting **System Host** MUST list System Host content only, and MUST NOT list folders. - **FR-011**: Every scope MUST continue to respect the requesting user's read permissions. -- **FR-012**: A given selection MUST return the same items whether or not a text search or a field filter is active. Filtering narrows a scope; it never changes which scope was asked for. +- **FR-012**: A text search or a field filter MUST only ever remove items from the selected scope, never add items from outside it. Filtering narrows a scope; it never changes which scope was asked for. The answer MUST NOT depend on which of the product's internal search paths served the request. #### Creating, uploading and moving @@ -118,15 +120,17 @@ Having selected some content, a user drags it onto the **System Host** entry to #### Persistence -- **FR-021**: The selected scope MUST travel in the URL alongside the other filters, so a reload, a browser back or forward, and a shared link all reopen the scope the sender was viewing. -- **FR-022**: A URL that names a scope MUST open on that scope. When that scope is All or System Host, a location carried elsewhere in the URL does not change what is listed, because neither scope points at a place in the hierarchy. -- **FR-023**: A URL that names no scope MUST open on the folder it points at, if it points at one, and on All otherwise. This is what keeps links made before this feature meaning what they meant: a link to a folder still opens that folder, and a link with no location still lists the whole site. -- **FR-024**: Changing the scope MUST return the listing to its first page and clear the current item selection, since neither carries any meaning across scopes. +- **FR-021**: The selection MUST be carried by the single URL value that already says where the drive is browsing, not by a second value beside it. Two values could disagree with each other, and then neither would be the answer. +- **FR-022**: That one value MUST be able to express all four selections: **absent** means All, `/` means the site root, a deeper path means that folder, and a reserved word means System Host. A reserved word MUST NOT be mistakable for a folder, which the leading `/` on every real path already guarantees. +- **FR-023**: Links made before this feature MUST keep meaning what they meant. A link carrying no location still lists the whole site, and a link to a folder still opens that folder. +- **FR-024**: A reload, a browser back or forward, and a shared link MUST all reopen the selection the sender was viewing. +- **FR-025**: Changing the selection MUST return the listing to its first page and clear the current item selection, since neither carries any meaning across selections. #### Not breaking what exists -- **FR-025**: A content listing requested without a scope MUST behave as it does today, so other surfaces that share this listing (notably the Asset Picker) are unaffected by this feature. -- **FR-026**: No consumer of the shared content-listing service other than Content Drive may change behavior. Only Content Drive's own requests carry a scope; every other caller MUST keep producing exactly the results it produces today. +- **FR-026**: A content listing requested without a scope MUST behave as it does today, so other surfaces that share this listing (notably the Asset Picker) are unaffected by this feature. +- **FR-027**: No consumer of the shared content-listing service other than Content Drive may change behavior. Only Content Drive's own requests carry a scope; every other caller MUST keep producing exactly the results it produces today. +- **FR-028**: That claim MUST be demonstrated rather than assumed. Every one of those consumers reaches the listing through a single shared seam, so the seam itself MUST be pinned by a test, and each named consumer MUST be recorded as either covered by that test or checked by inspection. None may be left unaccounted for. ### Key Entities @@ -144,7 +148,7 @@ Having selected some content, a user drags it onto the **System Host** entry to - **SC-004**: A given scope returns the same set of items with a search active as without one, minus only the items the search legitimately excludes. - **SC-005**: Opening a shared Content Drive link reproduces the scope the sender was viewing, every time. - **SC-006**: Content is moved to System Host in a single drag, with no dialog and no intermediate step. -- **SC-007**: Surfaces other than Content Drive that browse content list exactly what they listed before this change, and a listing asked for without a scope returns exactly what it returns today. +- **SC-007**: Surfaces other than Content Drive that browse content list exactly what they listed before this change, a listing asked for without a scope returns exactly what it returns today, and every consumer of that listing is accounted for by name rather than covered by a blanket claim. - **SC-008**: No content can be created, uploaded, or moved from the All view; every route that would place content somewhere is unavailable there. ## Legacy Considerations *(dotCMS-specific — mandatory)* @@ -152,7 +156,7 @@ Having selected some content, a user drags it onto the **System Host** entry to - **Existing behavior touched**: The Content Drive listing and the underlying content-browsing service it shares with the Asset Picker, which is long-standing code that predates Content Drive. The meaning of "the site is selected" changes deliberately: it becomes the site root rather than the whole site, with the whole-site view moving to its own **All** entry. That is a visible behavior change for existing Content Drive users and is the point of the feature. - **Backward-compatibility expectations**: **Preserving existing behavior is a hard constraint, not a preference.** The content-listing service Content Drive uses is shared with the assets API, the older file browser and its deprecated tree endpoint, the legacy admin browser, a Velocity viewtool, and two internal callers. None of them will ask for a scope, so whatever a scope-less request means today it must mean afterwards, byte for byte, including how System Host content is treated. Shared Content Drive links created before this change must keep restoring, including the value of the toggle being renamed. No deprecation of existing admin workflows is intended. - **A rename that lands on two surfaces**: the "Show Shared Assets" label is a single shared translation used by both the Content Drive toolbar and the Asset Picker toolbar. Renaming it to "Show System Host" renames the chip in the Asset Picker as well. That is consistent terminology rather than a regression, but it is a visible change on a surface this feature does not otherwise touch, and it should be an accepted decision rather than a surprise. -- **Known related decisions**: The listing has two internal query paths that can disagree about whether System Host content is included; they must agree before any scope can be trusted, which is why the spec requires a scope to return the same items with or without a search rather than leaving it as an implementation concern. Moving content and browsing content also address System Host by different means, so support for one does not imply support for the other. Issue #37166 is related: it touches how Content Drive reports operations and surfaced this while examining what a move actually changes in the listing. The plan phase will formally consult `dotCMS/platform-adrs`. +- **Known related decisions**: The listing has two internal query paths that can disagree about whether System Host content is included; they must agree before any scope can be trusted, which is why the spec requires a scope to return the same items with or without a search rather than leaving it as an implementation concern. Only one of the two runs by default, and the other is reachable only by configuration, so proving they agree means deliberately exercising the site-root and System Host scopes under each rather than waiting for the non-default one to show up on its own. Moving content and browsing content also address System Host by different means, so support for one does not imply support for the other. Issue #37166 is related: it touches how Content Drive reports operations and surfaced this while examining what a move actually changes in the listing. The plan phase will formally consult `dotCMS/platform-adrs`. ## Assumptions From 5147264d9e8e7087113e7265c4ace56088c438e8 Mon Sep 17 00:00:00 2001 From: Jalinson Diaz Date: Fri, 11 Sep 2026 16:48:54 -0300 Subject: [PATCH 04/44] docs(content-drive): say browse scope, never bare scope, for #37426 Content Drive is separately gaining a search scope (#37479), which says which fields a search reads. This one says where you are browsing. Two different things, and "scope" on its own would name either, so every mention here is now spelled in full and the Key Entities entry says what the other one is. Co-Authored-By: Claude Opus 5 --- .../37426-content-drive-browse-scopes/spec.md | 44 +++++++++---------- 1 file changed, 22 insertions(+), 22 deletions(-) diff --git a/specs/37426-content-drive-browse-scopes/spec.md b/specs/37426-content-drive-browse-scopes/spec.md index 69fe983a3cf0..67ee1dc55ae8 100644 --- a/specs/37426-content-drive-browse-scopes/spec.md +++ b/specs/37426-content-drive-browse-scopes/spec.md @@ -12,13 +12,13 @@ ## User Scenarios & Testing *(mandatory)* -Content Drive can express only one browse scope today. Selecting a site lists everything on it, at every depth, with System Host content mixed in. There is no way to ask for the items that sit **at** the site root, and no way to reach System Host content on its own. This feature splits that single scope into three the user picks from the sidebar. +Content Drive can express only one browse scope today. Selecting a site lists everything on it, at every depth, with System Host content mixed in. There is no way to ask for the items that sit **at** the site root, and no way to reach System Host content on its own. This feature splits that single browse scope into three the user picks from the sidebar. ### User Story 1 - Browse the site root, and browse the whole site, as separate things (Priority: P1) An editor opens Content Drive and wants to see what actually lives at the top of the site, not a flat list of every asset in every folder. Selecting the site in the hierarchy shows the site root: the items sitting there plus the site's top-level folders. When they do want the flat everything-on-this-site view, they select **All** at the top of the sidebar. -**Why this priority**: This is the defect at the heart of the request. "Browse the site" and "browse the site root" are the same request today, so one of the two views simply does not exist. Everything else in this feature builds on the sidebar having distinct, selectable scopes. +**Why this priority**: This is the defect at the heart of the request. "Browse the site" and "browse the site root" are the same request today, so one of the two views simply does not exist. Everything else in this feature builds on the sidebar having distinct, selectable browse scopes. **Independent Test**: On a site with content at the root and content nested in folders, select the site row and confirm only root-level items and top-level folders are listed; select All and confirm the nested content appears and no folders do. Delivers the missing root view without any System Host work. @@ -58,7 +58,7 @@ A user needs to find or manage assets shared across every site. Today those asse Having selected some content, a user drags it onto the **System Host** entry to share it across every site, the same gesture they already use to move content into a folder. -**Why this priority**: System Host is a real destination, so making it a drop target completes the interaction. It is separable from browsing: the scopes are useful before drag and drop is wired up. +**Why this priority**: System Host is a real destination, so making it a drop target completes the interaction. It is separable from browsing: the browse scopes are useful before drag and drop is wired up. **Independent Test**: Select an item on a site, drag it onto the System Host entry, and confirm it afterwards appears under System Host and no longer under the site. @@ -74,13 +74,13 @@ Having selected some content, a user drags it onto the **System Host** entry to ### Edge Cases - **A site with nothing at its root.** Selecting the site row shows the top-level folders and no content, or the empty state if the site has no folders either. It must not silently fall back to the everything view. -- **A search combined with a scope.** A text search narrows within the selected scope; it never widens it. Searching while the site row is selected must not start returning content from inside folders, and searching while System Host is selected must not start returning site content. -- **Restoring a shared link.** A link that carries a scope reopens on that scope. A link saved before this feature carries no scope and reopens on the view it produced before, so old links do not silently change meaning. +- **A search combined with a browse scope.** A text search narrows within the selected browse scope; it never widens it. Searching while the site row is selected must not start returning content from inside folders, and searching while System Host is selected must not start returning site content. +- **Restoring a shared link.** A link that names a browse scope reopens on it. A link saved before this feature names none and reopens on the view it produced before, so old links do not silently change meaning. - **Switching sites while System Host is selected.** System Host is not part of any site, so the selection survives the switch and the listing is unchanged. -- **A URL whose location is a reserved word rather than a path.** It selects that scope. The two cannot be confused, because every real folder path begins with `/` and no reserved word does, so no site can ever own a folder that collides with one. -- **A user without read access to System Host content.** Permission filtering applies to every scope, so the System Host entry can legitimately produce an empty listing for such a user. +- **A URL whose location is a reserved word rather than a path.** It selects that browse scope. The two cannot be confused, because every real folder path begins with `/` and no reserved word does, so no site can ever own a folder that collides with one. +- **A user without read access to System Host content.** Permission filtering applies to every browse scope, so the System Host entry can legitimately produce an empty listing for such a user. - **A user who may browse System Host but not add to it.** The System Host entry lists content but refuses uploads, creation and drops, the same way a folder the user cannot add to already behaves. -- **Switching scope mid-page.** A user on page 4 of All who selects System Host lands on the first page of System Host, with nothing carried over from the previous selection. +- **Switching browse scope mid-page.** A user on page 4 of All who selects System Host lands on the first page of System Host, with nothing carried over from the previous selection. ## Requirements *(mandatory)* @@ -94,15 +94,15 @@ Having selected some content, a user drags it onto the **System Host** entry to - **FR-004**: The sidebar MUST NOT show item counts next to any entry. - **FR-005**: Both sections MUST be reachable and selectable by keyboard, alongside the hierarchy they sit around. -#### What each scope lists +#### What each browse scope lists - **FR-006**: Selecting **All** MUST list the current site's content at any depth, and MUST NOT list folders. - **FR-007**: Selecting the **site row** MUST list only the items that sit at the site root, including the site's top-level folders. - **FR-008**: Selecting the **site row** MUST NOT include System Host content, under any setting. - **FR-009**: Selecting a **folder** MUST list its contents exactly as it does today. - **FR-010**: Selecting **System Host** MUST list System Host content only, and MUST NOT list folders. -- **FR-011**: Every scope MUST continue to respect the requesting user's read permissions. -- **FR-012**: A text search or a field filter MUST only ever remove items from the selected scope, never add items from outside it. Filtering narrows a scope; it never changes which scope was asked for. The answer MUST NOT depend on which of the product's internal search paths served the request. +- **FR-011**: Every browse scope MUST continue to respect the requesting user's read permissions. +- **FR-012**: A text search or a field filter MUST only ever remove items from the selected browse scope, never add items from outside it. Filtering narrows a browse scope; it never changes which browse scope was asked for. The answer MUST NOT depend on which of the product's internal search paths served the request. #### Creating, uploading and moving @@ -115,7 +115,7 @@ Having selected some content, a user drags it onto the **System Host** entry to - **FR-017**: The existing "Show Shared Assets" control MUST be renamed **"Show System Host"**. Only the label changes; links already in circulation that carry the control's current value MUST keep restoring correctly. - **FR-018**: The control MUST be offered only while **All** is selected. -- **FR-019**: The control MUST retain its value while another scope is selected, so returning to **All** restores the user's previous choice rather than resetting it. +- **FR-019**: The control MUST retain its value while another browse scope is selected, so returning to **All** restores the user's previous choice rather than resetting it. - **FR-020**: With the control on, **All** MUST include System Host content alongside the site's; with it off, **All** MUST exclude it. #### Persistence @@ -128,13 +128,13 @@ Having selected some content, a user drags it onto the **System Host** entry to #### Not breaking what exists -- **FR-026**: A content listing requested without a scope MUST behave as it does today, so other surfaces that share this listing (notably the Asset Picker) are unaffected by this feature. -- **FR-027**: No consumer of the shared content-listing service other than Content Drive may change behavior. Only Content Drive's own requests carry a scope; every other caller MUST keep producing exactly the results it produces today. +- **FR-026**: A content listing requested without a browse scope MUST behave as it does today, so other surfaces that share this listing (notably the Asset Picker) are unaffected by this feature. +- **FR-027**: No consumer of the shared content-listing service other than Content Drive may change behavior. Only Content Drive's own requests carry a browse scope; every other caller MUST keep producing exactly the results it produces today. - **FR-028**: That claim MUST be demonstrated rather than assumed. Every one of those consumers reaches the listing through a single shared seam, so the seam itself MUST be pinned by a test, and each named consumer MUST be recorded as either covered by that test or checked by inspection. None may be left unaccounted for. ### Key Entities -- **Browse scope**: Which slice of content the listing is being asked for. One of: the whole current site at any depth, the current site's root only, or System Host only. +- **Browse scope**: Which slice of content the listing is being asked for. One of: the whole current site at any depth, the current site's root only, or System Host only. Named in full throughout, because Content Drive is separately gaining a *search* scope that says which fields a search looks at. The two are independent: a browse scope says where you are, a search scope says how a search reads what is there. - **Site root**: The level of a site that is not inside any folder. Home to both root-level content and the site's top-level folders. - **System Host**: The site-independent container for content shared across every site. It holds no folders, and site content cannot live inside it. @@ -144,11 +144,11 @@ Having selected some content, a user drags it onto the **System Host** entry to - **SC-001**: A user can list the items sitting at a site's root without any content from inside that site's folders appearing, which is not possible today. - **SC-002**: A user can list System Host content on its own without any site's content appearing, which is not possible today. -- **SC-003**: In 100% of listings produced by the site-root and System Host scopes, every returned item belongs to the scope that was asked for. -- **SC-004**: A given scope returns the same set of items with a search active as without one, minus only the items the search legitimately excludes. -- **SC-005**: Opening a shared Content Drive link reproduces the scope the sender was viewing, every time. +- **SC-003**: In 100% of listings produced by the site-root and System Host browse scopes, every returned item belongs to the browse scope that was asked for. +- **SC-004**: A given browse scope returns the same set of items with a search active as without one, minus only the items the search legitimately excludes. +- **SC-005**: Opening a shared Content Drive link reproduces the browse scope the sender was viewing, every time. - **SC-006**: Content is moved to System Host in a single drag, with no dialog and no intermediate step. -- **SC-007**: Surfaces other than Content Drive that browse content list exactly what they listed before this change, a listing asked for without a scope returns exactly what it returns today, and every consumer of that listing is accounted for by name rather than covered by a blanket claim. +- **SC-007**: Surfaces other than Content Drive that browse content list exactly what they listed before this change, a listing asked for without a browse scope returns exactly what it returns today, and every consumer of that listing is accounted for by name rather than covered by a blanket claim. - **SC-008**: No content can be created, uploaded, or moved from the All view; every route that would place content somewhere is unavailable there. ## Legacy Considerations *(dotCMS-specific — mandatory)* @@ -156,13 +156,13 @@ Having selected some content, a user drags it onto the **System Host** entry to - **Existing behavior touched**: The Content Drive listing and the underlying content-browsing service it shares with the Asset Picker, which is long-standing code that predates Content Drive. The meaning of "the site is selected" changes deliberately: it becomes the site root rather than the whole site, with the whole-site view moving to its own **All** entry. That is a visible behavior change for existing Content Drive users and is the point of the feature. - **Backward-compatibility expectations**: **Preserving existing behavior is a hard constraint, not a preference.** The content-listing service Content Drive uses is shared with the assets API, the older file browser and its deprecated tree endpoint, the legacy admin browser, a Velocity viewtool, and two internal callers. None of them will ask for a scope, so whatever a scope-less request means today it must mean afterwards, byte for byte, including how System Host content is treated. Shared Content Drive links created before this change must keep restoring, including the value of the toggle being renamed. No deprecation of existing admin workflows is intended. - **A rename that lands on two surfaces**: the "Show Shared Assets" label is a single shared translation used by both the Content Drive toolbar and the Asset Picker toolbar. Renaming it to "Show System Host" renames the chip in the Asset Picker as well. That is consistent terminology rather than a regression, but it is a visible change on a surface this feature does not otherwise touch, and it should be an accepted decision rather than a surprise. -- **Known related decisions**: The listing has two internal query paths that can disagree about whether System Host content is included; they must agree before any scope can be trusted, which is why the spec requires a scope to return the same items with or without a search rather than leaving it as an implementation concern. Only one of the two runs by default, and the other is reachable only by configuration, so proving they agree means deliberately exercising the site-root and System Host scopes under each rather than waiting for the non-default one to show up on its own. Moving content and browsing content also address System Host by different means, so support for one does not imply support for the other. Issue #37166 is related: it touches how Content Drive reports operations and surfaced this while examining what a move actually changes in the listing. The plan phase will formally consult `dotCMS/platform-adrs`. +- **Known related decisions**: The listing has two internal query paths that can disagree about whether System Host content is included; they must agree before any browse scope can be trusted, which is why the spec requires a scope to return the same items with or without a search rather than leaving it as an implementation concern. Only one of the two runs by default, and the other is reachable only by configuration, so proving they agree means deliberately exercising the site-root and System Host scopes under each rather than waiting for the non-default one to show up on its own. Moving content and browsing content also address System Host by different means, so support for one does not imply support for the other. Issue #37166 is related: it touches how Content Drive reports operations and surfaced this while examining what a move actually changes in the listing. The plan phase will formally consult `dotCMS/platform-adrs`. ## Assumptions - **System Host accepts everything a folder accepts**: moved content, uploaded files, and newly created content. It is a real place to put things, unlike **All**, which spans the whole site and names no single destination. - **Selecting System Host survives a site switch**, because System Host belongs to no site and the listing would not change. -- **The site root scope shows the site's top-level folders.** They sit at the root, so they are part of what is "at" the root. This means the site-root and All scopes differ in their content, not in their folders, since All shows no folders at all. +- **The site root browse scope shows the site's top-level folders.** They sit at the root, so they are part of what is "at" the root. This means the site-root and All browse scopes differ in their content, not in their folders, since All shows no folders at all. - **The Move dialog is not being changed to match the drop.** Its destination picker filters System Host out of the site list whenever a folder destination is required, which is the mode the dialog runs in, so dragging onto System Host will be the only route to that destination. Whether the dialog should offer it too is a product decision, deliberately left outside this feature. -- **"Children of the site root" is deliberately not a scope.** System Host has no folders and site content cannot live under System Host, so the site hierarchy already covers browsing below the root. +- **"Children of the site root" is deliberately not a browse scope.** System Host has no folders and site content cannot live under System Host, so the site hierarchy already covers browsing below the root. - **Item counts beside the sidebar entries are prototype-only** and are not part of this feature. From afe61f14d2077144381bf94b5187b715415ee18c Mon Sep 17 00:00:00 2001 From: Jalinson Diaz Date: Fri, 11 Sep 2026 17:16:05 -0300 Subject: [PATCH 05/44] docs(content-drive): the contract delta and request model, for #37426 Two callers share POST /v1/drive/search, and one of them is owned elsewhere, so what the new field means when it is absent is the whole promise. Written down rather than left to the implementation: omitting the browse scope returns what it returns today, byte for byte, and the field is deliberately NOT defaulted to ALL, because ALL as unconditional folder-skipping would silently turn the Asset Picker's folder requests recursive. An explicit scope is valid only at the site root. A folder path with any scope is refused rather than resolved by precedence, following the bulk upload form: picking one of two contradictory statements puts content somewhere the caller did not choose. plan.md, research.md and quickstart.md stay local per .gitignore:228. Co-Authored-By: Claude Opus 5 --- .../contracts/drive-search-browse-scope.md | 72 +++++++++++++++++++ .../data-model.md | 68 ++++++++++++++++++ 2 files changed, 140 insertions(+) create mode 100644 specs/37426-content-drive-browse-scopes/contracts/drive-search-browse-scope.md create mode 100644 specs/37426-content-drive-browse-scopes/data-model.md diff --git a/specs/37426-content-drive-browse-scopes/contracts/drive-search-browse-scope.md b/specs/37426-content-drive-browse-scopes/contracts/drive-search-browse-scope.md new file mode 100644 index 000000000000..ed0e59b78a08 --- /dev/null +++ b/specs/37426-content-drive-browse-scopes/contracts/drive-search-browse-scope.md @@ -0,0 +1,72 @@ +# Contract delta: `browseScope` on `POST /api/v1/drive/search` + +**Feature**: [#37426](https://github.com/dotCMS/core/issues/37426) · **Spec**: [spec.md](../spec.md) · **Date**: 2026-09-11 + +This endpoint has two callers: Content Drive and the Asset Picker (`DotContentDriveService.search`). Both are in this repo, so the change is coordinated rather than published, but the Asset Picker is owned elsewhere and must not have to change. Everything below is written from that constraint. + +--- + +## The addition + +One optional field on the request body. + +```jsonc +{ + "assetPath": "//demo.dotcms.com/", + "browseScope": "ALL" | "ROOT" | "SYSTEM_HOST" // optional + // …every other field unchanged +} +``` + +There is no change to the response body, to any other request field, or to any status code other than the new 400 described below. + +--- + +## What each value means + +| `browseScope` | `assetPath` | Returns | +|---|---|---| +| *(omitted)* | `//site/` | Everything on the site, at any depth, System Host included when `includeSystemHost` is true. **Exactly today's behavior.** | +| *(omitted)* | `//site/folder/` | That folder's contents. **Exactly today's behavior.** | +| `ALL` | `//site/` | The same as omitting it at the site root. The explicit spelling. | +| `ROOT` | `//site/` | Only what sits at the site root. System Host is never included. | +| `SYSTEM_HOST` | `//site/` | System Host content only. The site portion of `assetPath` is context, not a filter, and is not read. | +| any value | `//site/folder/` | **400.** See below. | + +--- + +## The compatibility guarantee + +**A request that does not carry `browseScope` returns exactly what it returns today, byte for byte.** This is the contract's load-bearing promise, and it is what lets the Asset Picker stay untouched. Two consequences worth stating so they are not traded away later: + +The field is **not** defaulted to `ALL` in a way that changes behavior. `ALL` is meaningful only at the site root, and an omitted scope inside a folder is not the same as `ALL` inside a folder. Defaulting the field so that it reads more tidily in the schema would silently turn the Asset Picker's folder requests recursive. + +`includeSystemHost` keeps its current meaning and default. It is read when the scope is `ALL` or omitted, and ignored otherwise, because the other two scopes already answer the System Host question. + +--- + +## The refusal + +An explicit `browseScope` with a path that is not the site root is rejected with **400**, naming both values. + +The three scopes are things you can only be in at the root: the whole site, the root itself, and System Host. A folder is addressed by its path. There is therefore no combination of a folder path and a scope that means anything, and rather than resolve one by precedence the request is refused, following `BulkUploadForm.isExactlyOneTargetGiven`: "Both is refused rather than resolved by precedence. A caller that sends a folder and a site has said two contradictory things, and picking one would silently put an author's files somewhere they did not choose." + +```json +{ "message": "browseScope is only valid at the site root; got 'SYSTEM_HOST' with path '/application/'" } +``` + +--- + +## OpenAPI + +`openapi.yaml` is generated at compile time, so the description lives in the Java annotation and the regenerated file is committed alongside it. The `@Schema` must state three things a generator cannot infer: that omitting the field means today's behavior, that it is only valid at the site root, and that `includeSystemHost` is read only for `ALL`. + +Regenerate with `./mvnw compile -pl :dotcms-core --am -DskipTests`. + +--- + +## What is not in this contract + +`showFolders` stays the caller's decision. The endpoint honours whatever it is sent so the response always matches the request and the folder cursors never describe a query the caller did not make. The client suppresses folders in `ALL` and `SYSTEM_HOST`; the server does not do it for them. + +The frontend's URL encoding of the same choice (absent, `/`, a path, or `SYSTEM_HOST`, all in one value) is a Content Drive concern and is not part of this contract. The Asset Picker has no URL and no browse scope. diff --git a/specs/37426-content-drive-browse-scopes/data-model.md b/specs/37426-content-drive-browse-scopes/data-model.md new file mode 100644 index 000000000000..8632d3e64475 --- /dev/null +++ b/specs/37426-content-drive-browse-scopes/data-model.md @@ -0,0 +1,68 @@ +# Phase 1 Data Model: Content Drive browse scopes + +**Feature**: [spec.md](./spec.md) · **Plan**: [plan.md](./plan.md) · **Date**: 2026-09-11 + +No persisted data changes: no table, no column, no index mapping. Everything here is request-time state. + +--- + +## BrowseScope (new, request-level) + +Which slice of content the listing is asked for. + +| Value | Folder constraint | Host clause | Folders listed | +|---|---|---|---| +| *(absent)* | today's behavior: dropped at the site root, applied inside a folder | today's behavior | caller's choice | +| `ALL` | dropped, so every depth of the site | site, plus System Host when the toggle is on | no (client asks for none) | +| `ROOT` | applied at `/`, so only what sits at the root | site only | yes, the top-level folders | +| `SYSTEM_HOST` | applied at `/` | System Host only | no (System Host has none) | + +**Validation**: a value other than absent is accepted only when the request's path is the site root. With a folder path, any explicit value is a 400. Refused, never resolved by precedence. + +**Relationship to the existing `includeSystemHost`**: read only when the scope is `ALL` or absent. `ROOT` excludes System Host regardless; `SYSTEM_HOST` is System Host regardless. + +--- + +## SystemHostMode (new, internal to `BrowserQuery`) + +Replaces the `forceSystemHost` boolean. Not part of the REST contract. + +| Value | SQL emitted | Lucene emitted | +|---|---|---| +| `EXCLUDE` *(default)* | `and (id.host_inode = ?)` | `+conhost:` | +| `INCLUDE` | `and (id.host_inode = ? or id.host_inode = 'SYSTEM_HOST')` | `+(conhost: OR conhost:SYSTEM_HOST)` | +| `ONLY` | `and (id.host_inode = 'SYSTEM_HOST')` | `+conhost:SYSTEM_HOST` | + +`EXCLUDE` as the default is the whole backward-compatibility story: it reproduces exactly what the boolean `false` produces today, and no caller outside Content Drive ever set the flag. `ONLY` reaches `appendSystemHostQuery`, which exists today and is unreachable because it fires only when the query carries no site. + +**State transitions**: none. The mode is derived per request from the browse scope and the System Host toggle, never mutated after the query is built. + +--- + +## Location (frontend, one value) + +The store's existing `path` becomes the single statement of where the user is. It is the only thing the URL carries about location. + +| Value | Selection | Sent as | +|---|---|---| +| *(absent)* | All | `assetPath: ///`, `browseScope: ALL` | +| `/` | the site root | `assetPath: ///`, `browseScope: ROOT` | +| `/folder/…` | that folder | `assetPath: ///folder/…`, no scope | +| `SYSTEM_HOST` | System Host | `assetPath: ///`, `browseScope: SYSTEM_HOST` | + +Two rules protect this table. Reserved words can never collide with a folder, because every real path begins with `/` and no reserved word does. And the mapping to `assetPath` must be explicit rather than template interpolation: the current expression at `dot-content-drive.store.ts:129` would produce `//demo.dotcms.comSYSTEM_HOST`. + +**Why absent rather than an explicit `ALL` token**: the URL writer already removes the `path` parameter when the path is empty (`DEFAULT_PATH = undefined`), so links already in circulation carry no path and must keep meaning "the whole site". Absent is not a gap in the model, it is the back-compatible spelling of All. + +--- + +## Sidebar selection (frontend) + +Exactly one of four things is selected: the All row, the site row, a folder node, or the System Host row. The tree's existing `selectedNode` continues to represent the middle two; the two new rows live outside the tree and must clear it when chosen, and be cleared by it. + +| Selection | Drop target | Add content | +|---|---|---| +| All | no | no | +| site row | yes, as today | yes, as today | +| folder | yes, as today | yes, as today | +| System Host | yes, moves content there | yes, gated against System Host itself | From 7c52553e58d6d44fb70ce7e2d1c41655f2095e9f Mon Sep 17 00:00:00 2001 From: Jalinson Diaz Date: Mon, 14 Sep 2026 09:52:03 -0300 Subject: [PATCH 06/44] docs(content-drive): name the scope, pin the row, keep the toggle in place "All" said nothing about what it was the all of, and Content Drive is separately gaining a search scope whose own option reads "All Content". It is now All Site Content, which also explains why it sits above a section headed SITE HIERARCHY: the first row is the site seen flat, the section below is the same site seen by folder. System Host is fixed at the bottom while the hierarchy scrolls beneath it. The tree loads lazily and grows as folders open, so its bottom edge moves, and an entry placed after it would get steadily harder to reach. The top row needs no such treatment, since scrolling back up is a cheap, known gesture. The Show System Host toggle stays visible everywhere and is disabled outside All Site Content, saying why. Hiding it reflowed the filter row and moved the neighbouring controls under the pointer on every selection change. Switching sites while System Host is selected keeps the selection, re-renders the hierarchy for the new site so the switch visibly does something, and keeps the highlight on System Host rather than letting it drift onto the new root. Co-Authored-By: Claude Opus 5 --- .../data-model.md | 8 +-- .../37426-content-drive-browse-scopes/spec.md | 49 ++++++++++--------- 2 files changed, 29 insertions(+), 28 deletions(-) diff --git a/specs/37426-content-drive-browse-scopes/data-model.md b/specs/37426-content-drive-browse-scopes/data-model.md index 8632d3e64475..0dba0857904a 100644 --- a/specs/37426-content-drive-browse-scopes/data-model.md +++ b/specs/37426-content-drive-browse-scopes/data-model.md @@ -45,24 +45,24 @@ The store's existing `path` becomes the single statement of where the user is. I | Value | Selection | Sent as | |---|---|---| -| *(absent)* | All | `assetPath: ///`, `browseScope: ALL` | +| *(absent)* | All Site Content | `assetPath: ///`, `browseScope: ALL` | | `/` | the site root | `assetPath: ///`, `browseScope: ROOT` | | `/folder/…` | that folder | `assetPath: ///folder/…`, no scope | | `SYSTEM_HOST` | System Host | `assetPath: ///`, `browseScope: SYSTEM_HOST` | Two rules protect this table. Reserved words can never collide with a folder, because every real path begins with `/` and no reserved word does. And the mapping to `assetPath` must be explicit rather than template interpolation: the current expression at `dot-content-drive.store.ts:129` would produce `//demo.dotcms.comSYSTEM_HOST`. -**Why absent rather than an explicit `ALL` token**: the URL writer already removes the `path` parameter when the path is empty (`DEFAULT_PATH = undefined`), so links already in circulation carry no path and must keep meaning "the whole site". Absent is not a gap in the model, it is the back-compatible spelling of All. +**Why absent rather than an explicit `ALL` token**: the URL writer already removes the `path` parameter when the path is empty (`DEFAULT_PATH = undefined`), so links already in circulation carry no path and must keep meaning "the whole site". Absent is not a gap in the model, it is the back-compatible spelling of All Site Content. --- ## Sidebar selection (frontend) -Exactly one of four things is selected: the All row, the site row, a folder node, or the System Host row. The tree's existing `selectedNode` continues to represent the middle two; the two new rows live outside the tree and must clear it when chosen, and be cleared by it. +Exactly one of four things is selected: the All Site Content row, the site row, a folder node, or the System Host row. The tree's existing `selectedNode` continues to represent the middle two; the two new rows live outside the tree and must clear it when chosen, and be cleared by it. The System Host row is fixed at the bottom of the sidebar and the hierarchy scrolls beneath it, so a growing tree can never push it out of reach. | Selection | Drop target | Add content | |---|---|---| -| All | no | no | +| All Site Content | no | no | | site row | yes, as today | yes, as today | | folder | yes, as today | yes, as today | | System Host | yes, moves content there | yes, gated against System Host itself | diff --git a/specs/37426-content-drive-browse-scopes/spec.md b/specs/37426-content-drive-browse-scopes/spec.md index 67ee1dc55ae8..17867df48513 100644 --- a/specs/37426-content-drive-browse-scopes/spec.md +++ b/specs/37426-content-drive-browse-scopes/spec.md @@ -16,21 +16,21 @@ Content Drive can express only one browse scope today. Selecting a site lists ev ### User Story 1 - Browse the site root, and browse the whole site, as separate things (Priority: P1) -An editor opens Content Drive and wants to see what actually lives at the top of the site, not a flat list of every asset in every folder. Selecting the site in the hierarchy shows the site root: the items sitting there plus the site's top-level folders. When they do want the flat everything-on-this-site view, they select **All** at the top of the sidebar. +An editor opens Content Drive and wants to see what actually lives at the top of the site, not a flat list of every asset in every folder. Selecting the site in the hierarchy shows the site root: the items sitting there plus the site's top-level folders. When they do want the flat everything-on-this-site view, they select **All Site Content** at the top of the sidebar. **Why this priority**: This is the defect at the heart of the request. "Browse the site" and "browse the site root" are the same request today, so one of the two views simply does not exist. Everything else in this feature builds on the sidebar having distinct, selectable browse scopes. -**Independent Test**: On a site with content at the root and content nested in folders, select the site row and confirm only root-level items and top-level folders are listed; select All and confirm the nested content appears and no folders do. Delivers the missing root view without any System Host work. +**Independent Test**: On a site with content at the root and content nested in folders, select the site row and confirm only root-level items and top-level folders are listed; select All Site Content and confirm the nested content appears and no folders do. Delivers the missing root view without any System Host work. **Acceptance Scenarios**: 1. **Given** a site with an asset at its root and another asset inside a folder, **When** the user selects the site row in the hierarchy, **Then** the listing shows the root asset and the site's top-level folders, and does not show the asset that lives inside a folder. -2. **Given** the same site, **When** the user selects **All**, **Then** the listing shows both assets and shows no folders at all. -3. **Given** the user has selected **All**, **When** they select a folder in the hierarchy, **Then** the listing shows that folder's contents exactly as it does today. +2. **Given** the same site, **When** the user selects **All Site Content**, **Then** the listing shows both assets and shows no folders at all. +3. **Given** the user has selected **All Site Content**, **When** they select a folder in the hierarchy, **Then** the listing shows that folder's contents exactly as it does today. 4. **Given** the user has selected the site row, **When** they look at the listing, **Then** no System Host content appears in it regardless of any other setting. 5. **Given** any of the three sidebar entries is selected, **When** the user selects a different one, **Then** the previous selection is cleared, so exactly one entry is ever active. -6. **Given** the user has selected **All**, **When** they look for the ways to add content, **Then** uploading and creating are visibly unavailable, and dragging content over the listing offers no drop target, because **All** spans the whole site and names no single place to put anything. -7. **Given** the user is on a later page of **All**, **When** they select a different sidebar entry, **Then** the listing starts again at its first page with no items still selected. +6. **Given** the user has selected **All Site Content**, **When** they look for the ways to add content, **Then** uploading and creating are visibly unavailable, and dragging content over the listing offers no drop target, because **All Site Content** spans the whole site and names no single place to put anything. +7. **Given** the user is on a later page of **All Site Content**, **When** they select a different sidebar entry, **Then** the listing starts again at its first page with no items still selected. 8. **Given** the site row is selected and a search is running, **When** the search is served by either of the product's two internal search paths, **Then** both return the same items, and neither admits content from inside a folder or from System Host. --- @@ -47,9 +47,9 @@ A user needs to find or manage assets shared across every site. Today those asse 1. **Given** content exists on System Host and on the current site, **When** the user selects **System Host**, **Then** only the System Host content is listed. 2. **Given** the user has selected **System Host**, **When** they look at the listing, **Then** no folders are offered, because System Host has none. -3. **Given** the user has selected **All** with "Show System Host" on, **When** they look at the listing, **Then** System Host content appears alongside the current site's content. -4. **Given** the user has selected **All** with "Show System Host" off, **When** they look at the listing, **Then** no System Host content appears. -5. **Given** the user has selected the site row or a folder, **When** they look at the filter bar, **Then** the "Show System Host" control is not offered, because it cannot apply there. +3. **Given** the user has selected **All Site Content** with "Show System Host" on, **When** they look at the listing, **Then** System Host content appears alongside the current site's content. +4. **Given** the user has selected **All Site Content** with "Show System Host" off, **When** they look at the listing, **Then** no System Host content appears. +5. **Given** the user has selected the site row or a folder, **When** they look at the filter bar, **Then** the "Show System Host" control is still there but disabled, and says why: it has nothing to decide outside **All Site Content**, because System Host content can only ever sit at the System Host root. 6. **Given** **System Host** is selected and a search is running, **When** the search is served by either of the product's two internal search paths, **Then** both return the same items, and neither admits content belonging to a site. --- @@ -65,7 +65,7 @@ Having selected some content, a user drags it onto the **System Host** entry to **Acceptance Scenarios**: 1. **Given** the user has selected content on a site, **When** they drop it onto the **System Host** entry, **Then** the content is moved to System Host and the listing reflects the move. -2. **Given** the user is dragging content, **When** they drag it over the **All** entry, **Then** it is not offered as a drop target and nothing is moved. +2. **Given** the user is dragging content, **When** they drag it over the **All Site Content** entry, **Then** it is not offered as a drop target and nothing is moved. 3. **Given** the user is dragging content, **When** they drag it over the site row or a folder, **Then** it behaves exactly as it does today. 4. **Given** the user lacks permission to add content to System Host, **When** they drag content over the **System Host** entry, **Then** it is not offered as a drop target. @@ -76,11 +76,11 @@ Having selected some content, a user drags it onto the **System Host** entry to - **A site with nothing at its root.** Selecting the site row shows the top-level folders and no content, or the empty state if the site has no folders either. It must not silently fall back to the everything view. - **A search combined with a browse scope.** A text search narrows within the selected browse scope; it never widens it. Searching while the site row is selected must not start returning content from inside folders, and searching while System Host is selected must not start returning site content. - **Restoring a shared link.** A link that names a browse scope reopens on it. A link saved before this feature names none and reopens on the view it produced before, so old links do not silently change meaning. -- **Switching sites while System Host is selected.** System Host is not part of any site, so the selection survives the switch and the listing is unchanged. +- **Switching sites while System Host is selected.** The selection survives, because System Host belongs to no site and its content does not change. The **hierarchy below re-renders for the newly chosen site**, so the switch visibly does something rather than appearing to fail, and the selected state MUST stay on the System Host entry: if the highlight drifts onto the new site's root, the sidebar claims the user is in two places at once. - **A URL whose location is a reserved word rather than a path.** It selects that browse scope. The two cannot be confused, because every real folder path begins with `/` and no reserved word does, so no site can ever own a folder that collides with one. - **A user without read access to System Host content.** Permission filtering applies to every browse scope, so the System Host entry can legitimately produce an empty listing for such a user. - **A user who may browse System Host but not add to it.** The System Host entry lists content but refuses uploads, creation and drops, the same way a folder the user cannot add to already behaves. -- **Switching browse scope mid-page.** A user on page 4 of All who selects System Host lands on the first page of System Host, with nothing carried over from the previous selection. +- **Switching browse scope mid-page.** A user on page 4 of All Site Content who selects System Host lands on the first page of System Host, with nothing carried over from the previous selection. ## Requirements *(mandatory)* @@ -88,15 +88,16 @@ Having selected some content, a user drags it onto the **System Host** entry to #### Sidebar structure -- **FR-001**: The sidebar MUST offer three kinds of selection: an **All** entry at the top, the **site hierarchy** (the site row and its folders), and a **System Host** entry at the bottom. -- **FR-002**: **All** and **System Host** MUST be presented as plain sidebar sections, not as nodes of the site hierarchy: no expansion control, no children, and nothing beneath them to navigate into. This is about structure only. It does not stop System Host accepting content, which the rules below require of it. +- **FR-001**: The sidebar MUST offer three kinds of selection: an **All Site Content** entry at the top, the **site hierarchy** (the site row and its folders), and a **System Host** entry at the bottom. +- **FR-001a**: The **System Host** entry MUST stay fixed at the bottom of the sidebar while the hierarchy scrolls beneath it. The hierarchy loads lazily and grows as folders are expanded, so its bottom edge moves; an entry positioned after it would become progressively harder to reach. The top entry needs no such treatment, since scrolling back to the top is a known, cheap gesture. +- **FR-002**: **All Site Content** and **System Host** MUST be presented as plain sidebar sections, not as nodes of the site hierarchy: no expansion control, no children, and nothing beneath them to navigate into. This is about structure only. It does not stop System Host accepting content, which the rules below require of it. - **FR-003**: Exactly one entry MUST be selected at any time; selecting one clears the previous selection. - **FR-004**: The sidebar MUST NOT show item counts next to any entry. - **FR-005**: Both sections MUST be reachable and selectable by keyboard, alongside the hierarchy they sit around. #### What each browse scope lists -- **FR-006**: Selecting **All** MUST list the current site's content at any depth, and MUST NOT list folders. +- **FR-006**: Selecting **All Site Content** MUST list the current site's content at any depth, and MUST NOT list folders. - **FR-007**: Selecting the **site row** MUST list only the items that sit at the site root, including the site's top-level folders. - **FR-008**: Selecting the **site row** MUST NOT include System Host content, under any setting. - **FR-009**: Selecting a **folder** MUST list its contents exactly as it does today. @@ -106,7 +107,7 @@ Having selected some content, a user drags it onto the **System Host** entry to #### Creating, uploading and moving -- **FR-013**: **All** MUST be a read-only view. Uploading, creating content or folders, and dropping content are all unavailable while it is selected, and the affordances MUST be visibly unavailable rather than offered and then refused. +- **FR-013**: **All Site Content** MUST be a read-only view. Uploading, creating content or folders, and dropping content are all unavailable while it is selected, and the affordances MUST be visibly unavailable rather than offered and then refused. - **FR-014**: The **site row**, a **folder**, and **System Host** MUST accept new content: uploads, creation, and content dropped onto them. For the site row and folders this is exactly today's behavior. - **FR-015**: Dropping content onto the **System Host** entry MUST move it to System Host. - **FR-016**: While **System Host** is selected, the permission check that gates creating and uploading MUST be evaluated against System Host itself, never against whichever site is selected in the site switcher. @@ -114,14 +115,14 @@ Having selected some content, a user drags it onto the **System Host** entry to #### The System Host toggle - **FR-017**: The existing "Show Shared Assets" control MUST be renamed **"Show System Host"**. Only the label changes; links already in circulation that carry the control's current value MUST keep restoring correctly. -- **FR-018**: The control MUST be offered only while **All** is selected. -- **FR-019**: The control MUST retain its value while another browse scope is selected, so returning to **All** restores the user's previous choice rather than resetting it. -- **FR-020**: With the control on, **All** MUST include System Host content alongside the site's; with it off, **All** MUST exclude it. +- **FR-018**: The control MUST remain visible in every browse scope but be **disabled** outside **All Site Content**, and it MUST say why it is disabled. Hiding it would reflow the filter row and move the neighbouring controls under the pointer every time the selection changes; a disabled control with no explanation is just something people click at twice. +- **FR-019**: The control MUST retain its value while another browse scope is selected, so returning to **All Site Content** restores the user's previous choice rather than resetting it. +- **FR-020**: With the control on, **All Site Content** MUST include System Host content alongside the site's; with it off, **All Site Content** MUST exclude it. #### Persistence - **FR-021**: The selection MUST be carried by the single URL value that already says where the drive is browsing, not by a second value beside it. Two values could disagree with each other, and then neither would be the answer. -- **FR-022**: That one value MUST be able to express all four selections: **absent** means All, `/` means the site root, a deeper path means that folder, and a reserved word means System Host. A reserved word MUST NOT be mistakable for a folder, which the leading `/` on every real path already guarantees. +- **FR-022**: That one value MUST be able to express all four selections: **absent** means All Site Content, `/` means the site root, a deeper path means that folder, and a reserved word means System Host. A reserved word MUST NOT be mistakable for a folder, which the leading `/` on every real path already guarantees. - **FR-023**: Links made before this feature MUST keep meaning what they meant. A link carrying no location still lists the whole site, and a link to a folder still opens that folder. - **FR-024**: A reload, a browser back or forward, and a shared link MUST all reopen the selection the sender was viewing. - **FR-025**: Changing the selection MUST return the listing to its first page and clear the current item selection, since neither carries any meaning across selections. @@ -149,20 +150,20 @@ Having selected some content, a user drags it onto the **System Host** entry to - **SC-005**: Opening a shared Content Drive link reproduces the browse scope the sender was viewing, every time. - **SC-006**: Content is moved to System Host in a single drag, with no dialog and no intermediate step. - **SC-007**: Surfaces other than Content Drive that browse content list exactly what they listed before this change, a listing asked for without a browse scope returns exactly what it returns today, and every consumer of that listing is accounted for by name rather than covered by a blanket claim. -- **SC-008**: No content can be created, uploaded, or moved from the All view; every route that would place content somewhere is unavailable there. +- **SC-008**: No content can be created, uploaded, or moved from the All Site Content view; every route that would place content somewhere is unavailable there. ## Legacy Considerations *(dotCMS-specific — mandatory)* -- **Existing behavior touched**: The Content Drive listing and the underlying content-browsing service it shares with the Asset Picker, which is long-standing code that predates Content Drive. The meaning of "the site is selected" changes deliberately: it becomes the site root rather than the whole site, with the whole-site view moving to its own **All** entry. That is a visible behavior change for existing Content Drive users and is the point of the feature. +- **Existing behavior touched**: The Content Drive listing and the underlying content-browsing service it shares with the Asset Picker, which is long-standing code that predates Content Drive. The meaning of "the site is selected" changes deliberately: it becomes the site root rather than the whole site, with the whole-site view moving to its own **All Site Content** entry. That is a visible behavior change for existing Content Drive users and is the point of the feature. - **Backward-compatibility expectations**: **Preserving existing behavior is a hard constraint, not a preference.** The content-listing service Content Drive uses is shared with the assets API, the older file browser and its deprecated tree endpoint, the legacy admin browser, a Velocity viewtool, and two internal callers. None of them will ask for a scope, so whatever a scope-less request means today it must mean afterwards, byte for byte, including how System Host content is treated. Shared Content Drive links created before this change must keep restoring, including the value of the toggle being renamed. No deprecation of existing admin workflows is intended. - **A rename that lands on two surfaces**: the "Show Shared Assets" label is a single shared translation used by both the Content Drive toolbar and the Asset Picker toolbar. Renaming it to "Show System Host" renames the chip in the Asset Picker as well. That is consistent terminology rather than a regression, but it is a visible change on a surface this feature does not otherwise touch, and it should be an accepted decision rather than a surprise. - **Known related decisions**: The listing has two internal query paths that can disagree about whether System Host content is included; they must agree before any browse scope can be trusted, which is why the spec requires a scope to return the same items with or without a search rather than leaving it as an implementation concern. Only one of the two runs by default, and the other is reachable only by configuration, so proving they agree means deliberately exercising the site-root and System Host scopes under each rather than waiting for the non-default one to show up on its own. Moving content and browsing content also address System Host by different means, so support for one does not imply support for the other. Issue #37166 is related: it touches how Content Drive reports operations and surfaced this while examining what a move actually changes in the listing. The plan phase will formally consult `dotCMS/platform-adrs`. ## Assumptions -- **System Host accepts everything a folder accepts**: moved content, uploaded files, and newly created content. It is a real place to put things, unlike **All**, which spans the whole site and names no single destination. +- **System Host accepts everything a folder accepts**: moved content, uploaded files, and newly created content. It is a real place to put things, unlike **All Site Content**, which spans the whole site and names no single destination. - **Selecting System Host survives a site switch**, because System Host belongs to no site and the listing would not change. -- **The site root browse scope shows the site's top-level folders.** They sit at the root, so they are part of what is "at" the root. This means the site-root and All browse scopes differ in their content, not in their folders, since All shows no folders at all. +- **The site root browse scope shows the site's top-level folders.** They sit at the root, so they are part of what is "at" the root. This means the site-root and All Site Content browse scopes differ in their content, not in their folders, since All Site Content shows no folders at all. - **The Move dialog is not being changed to match the drop.** Its destination picker filters System Host out of the site list whenever a folder destination is required, which is the mode the dialog runs in, so dragging onto System Host will be the only route to that destination. Whether the dialog should offer it too is a product decision, deliberately left outside this feature. - **"Children of the site root" is deliberately not a browse scope.** System Host has no folders and site content cannot live under System Host, so the site hierarchy already covers browsing below the root. - **Item counts beside the sidebar entries are prototype-only** and are not part of this feature. From 0ddb7a49412adddccfa712e062b8ce3dbca0f602 Mon Sep 17 00:00:00 2001 From: Jalinson Diaz Date: Mon, 14 Sep 2026 09:54:21 -0300 Subject: [PATCH 07/44] docs(content-drive): accept the missing location column, on the record A flat view over a whole site cannot tell two files of the same name in different folders apart, because the listing has no path column. The gap is inherited rather than introduced: today's site view is already that flat view. Naming it and making it the default raises the cost, so it is written down as consciously accepted with the fix named, rather than left for a reviewer or QA to rediscover as a defect in this work. Co-Authored-By: Claude Opus 5 --- specs/37426-content-drive-browse-scopes/spec.md | 1 + 1 file changed, 1 insertion(+) diff --git a/specs/37426-content-drive-browse-scopes/spec.md b/specs/37426-content-drive-browse-scopes/spec.md index 17867df48513..708071247ff6 100644 --- a/specs/37426-content-drive-browse-scopes/spec.md +++ b/specs/37426-content-drive-browse-scopes/spec.md @@ -164,6 +164,7 @@ Having selected some content, a user drags it onto the **System Host** entry to - **System Host accepts everything a folder accepts**: moved content, uploaded files, and newly created content. It is a real place to put things, unlike **All Site Content**, which spans the whole site and names no single destination. - **Selecting System Host survives a site switch**, because System Host belongs to no site and the listing would not change. - **The site root browse scope shows the site's top-level folders.** They sit at the root, so they are part of what is "at" the root. This means the site-root and All Site Content browse scopes differ in their content, not in their folders, since All Site Content shows no folders at all. +- **Where an item lives is not shown in All Site Content, and that is accepted for now.** The listing carries no path column, so a flat view spanning the whole site cannot tell two files of the same name in different folders apart. The gap is inherited rather than introduced: today's site view is already this flat view. Naming the view and making it the default does raise the cost of it, and a location column shown when the listing spans more than one folder is the fix, but it is being handled separately and is not a defect in this work. - **The Move dialog is not being changed to match the drop.** Its destination picker filters System Host out of the site list whenever a folder destination is required, which is the mode the dialog runs in, so dragging onto System Host will be the only route to that destination. Whether the dialog should offer it too is a product decision, deliberately left outside this feature. - **"Children of the site root" is deliberately not a browse scope.** System Host has no folders and site content cannot live under System Host, so the site hierarchy already covers browsing below the root. - **Item counts beside the sidebar entries are prototype-only** and are not part of this feature. From 8d622ea5ceada59dd083d187a82dee578a46cb3d Mon Sep 17 00:00:00 2001 From: Jalinson Diaz Date: Mon, 14 Sep 2026 10:15:16 -0300 Subject: [PATCH 08/44] test(browser): pin the host clause a listing emits when nobody asks, for #37426 Content Drive needs a System-Host-only clause, which means replacing the forceSystemHost boolean with a three-state. Seven callers reach the same builder and none of them sets that flag: the assets API, the older file browser and its deprecated tree endpoint, the legacy admin browser, a Velocity viewtool, and two internal callers. If the new default emits anything but "this site alone", every one of them silently changes what it returns, and nothing in the suite notices. This passes today, deliberately. A regression guard that failed first would be guarding something else. It asserts the emitted SQL rather than how the builder is invoked, so the refactor should not need to touch this file; if it does, that is the signal to look harder. Verified green before the refactor: 2 tests, 0 failures. Co-Authored-By: Claude Opus 5 --- .../browser/BrowserQueryHostClauseTest.java | 131 ++++++++++++++++++ 1 file changed, 131 insertions(+) create mode 100644 dotCMS/src/test/java/com/dotcms/browser/BrowserQueryHostClauseTest.java diff --git a/dotCMS/src/test/java/com/dotcms/browser/BrowserQueryHostClauseTest.java b/dotCMS/src/test/java/com/dotcms/browser/BrowserQueryHostClauseTest.java new file mode 100644 index 000000000000..1fc94adbfe7b --- /dev/null +++ b/dotCMS/src/test/java/com/dotcms/browser/BrowserQueryHostClauseTest.java @@ -0,0 +1,131 @@ +package com.dotcms.browser; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.CALLS_REAL_METHODS; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import com.dotcms.contenttype.model.type.BaseContentType; +import com.dotmarketing.beans.Host; +import com.dotmarketing.portlets.folders.model.Folder; +import java.lang.reflect.Field; +import java.lang.reflect.Method; +import java.util.List; +import java.util.Set; +import org.junit.Test; + +/** + * The host clause a listing emits when the caller says nothing about System Host. + * + *

This is a characterization test, and it passes the day it is written. That is the + * point of it. Content Drive is gaining a browse scope that needs the System-Host-only clause, + * which means replacing {@code forceSystemHost} with a three-state. Seven callers reach this same + * builder and none of them sets that flag: the assets API, the older file browser and its + * deprecated tree endpoint, the legacy admin browser, a Velocity viewtool, and two internal + * callers in the file-asset and folder factories. If the three-state's default emits anything + * other than the clause asserted below, every one of them silently changes what it returns.

+ * + *

A regression guard that failed first would be guarding something else. This one is committed + * before the refactor and must stay green through it; the assertions below are deliberately about + * the emitted SQL rather than about how the builder is invoked, so the refactor should not need + * to touch this file at all. If it does, that is the signal to look harder.

+ * + *

Exercised through the real SQL-building method by reflection, with no {@code APILocator} + * bootstrap and no database, following {@link BrowserAPIMimeTypeQueryTest}.

+ */ +public class BrowserQueryHostClauseTest { + + private static final String SITE_ID = "48190c8c-42c4-46af-8d1a-0cd5db894797"; + + /** + * Given a query that names a site and says nothing about System Host — the shape every caller + * outside Content Drive builds — When the statement is built, Then it is scoped to that site + * alone: one bound identifier, and no System Host literal anywhere in the text. + */ + @Test + public void testAQueryThatSaysNothingAboutSystemHostIsScopedToItsSiteAlone() throws Exception { + final SelectResult result = buildSelect(); + + assertTrue("the statement must filter on the site's identifier", + result.sql.contains("id.host_inode = ?")); + assertFalse("a caller that asked for nothing must not be given System Host content", + result.sql.contains("SYSTEM_HOST")); + assertEquals("the site identifier is bound, never concatenated", SITE_ID, + String.valueOf(result.params.get(0))); + } + + /** + * The same guarantee stated the other way round, because the failure this protects against is + * a widened clause rather than a missing one: the site predicate must not be OR'd with + * anything. An {@code OR} in this fragment is how "only this site" becomes "this site plus + * everything shared", which is exactly the silent change being guarded. + */ + @Test + public void testTheSitePredicateIsNotWidenedByAnOr() throws Exception { + final SelectResult result = buildSelect(); + + final int hostClauseStart = result.sql.indexOf("id.host_inode"); + assertTrue("the host predicate must be present to be checked", hostClauseStart >= 0); + + final String hostClause = result.sql.substring(hostClauseStart, + result.sql.indexOf(')', hostClauseStart) + 1); + assertFalse("the host predicate must stand alone: " + hostClause, + hostClause.toUpperCase().contains(" OR ")); + } + + /** + * Builds the statement for a query that names a site and a folder and nothing else, which is + * the default shape of every non-Content-Drive caller. + */ + private static SelectResult buildSelect() throws Exception { + final BrowserAPIImpl api = mock(BrowserAPIImpl.class, CALLS_REAL_METHODS); + final BrowserQuery query = mock(BrowserQuery.class, CALLS_REAL_METHODS); + + for (final String emptyCollection : List.of("languageIds", "contentTypeIds", + "excludedContentTypeIds", "workflowSchemeIds", "workflowStepIds", + "contentStatuses")) { + setField(query, emptyCollection, Set.of()); + } + setField(query, "baseTypes", Set.of(BaseContentType.ANY)); + setField(query, "fieldCriteria", List.of()); + setField(query, "mimeTypes", List.of()); + + // Both are mocked rather than constructed: `new Host()` resolves its content type through + // the legacy cache, which calls APILocator.systemUser() and reaches for a database + // connection. Only two accessors are read while the statement is built. + final Host site = mock(Host.class); + when(site.getIdentifier()).thenReturn(SITE_ID); + setField(query, "site", site); + + final Folder folder = mock(Folder.class); + when(folder.getPath()).thenReturn("/"); + setField(query, "folder", folder); + + final Method selectQuery = + BrowserAPIImpl.class.getDeclaredMethod("selectQuery", BrowserQuery.class); + selectQuery.setAccessible(true); + final BrowserAPIImpl.SelectQuery built = + (BrowserAPIImpl.SelectQuery) selectQuery.invoke(api, query); + + return new SelectResult(built.selectQuery, built.params); + } + + private static void setField(final BrowserQuery query, final String name, final Object value) + throws Exception { + final Field field = BrowserQuery.class.getDeclaredField(name); + field.setAccessible(true); + field.set(query, value); + } + + private static class SelectResult { + private final String sql; + private final List params; + + private SelectResult(final String sql, final List params) { + this.sql = sql; + this.params = params; + } + } +} From a4fb9e2e7d6d5c93e2c097bbb0821689dacaddb7 Mon Sep 17 00:00:00 2001 From: Jalinson Diaz Date: Mon, 14 Sep 2026 10:43:24 -0300 Subject: [PATCH 09/44] =?UTF-8?q?test(drive):=20red=20=E2=80=94=20a=20brow?= =?UTF-8?q?se=20scope=20with=20a=20folder=20path=20is=20not=20yet=20refuse?= =?UTF-8?q?d=20(#37426)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The three browse scopes are things you can only be in at the top of a site, so a scope named alongside a folder path is two contradictory statements. This pins that it must be refused rather than resolved by precedence, following the bulk upload form: picking one of two contradictory statements puts content somewhere the caller did not choose. Deliberately not pinned to an exception type — where the refusal is raised is an implementation choice, and naming the type would decide it from the test. What is asserted is that the request does not succeed and that the message names both halves, which is also what keeps the broad type honest: an incidental NPE has no message and fails rather than passing as a refusal that never happened. The enum and the field are here because the test cannot compile without them. The behavior under test is the refusal, and it is absent: 1 failure, "expected RuntimeException to be thrown, but nothing was thrown". The other two cases pass already — every scope at the site root, and a folder path with no scope, which is the Asset Picker's shape and the reason the field carries no default. Co-Authored-By: Claude Opus 5 --- .../v1/drive/AbstractDriveRequestForm.java | 24 +++++ .../dotcms/rest/api/v1/drive/BrowseScope.java | 26 +++++ .../DriveRequestFormBrowseScopeTest.java | 94 +++++++++++++++++++ 3 files changed, 144 insertions(+) create mode 100644 dotCMS/src/main/java/com/dotcms/rest/api/v1/drive/BrowseScope.java create mode 100644 dotCMS/src/test/java/com/dotcms/rest/api/v1/drive/DriveRequestFormBrowseScopeTest.java diff --git a/dotCMS/src/main/java/com/dotcms/rest/api/v1/drive/AbstractDriveRequestForm.java b/dotCMS/src/main/java/com/dotcms/rest/api/v1/drive/AbstractDriveRequestForm.java index 8cd8e4004f4e..e7e9525fd330 100644 --- a/dotCMS/src/main/java/com/dotcms/rest/api/v1/drive/AbstractDriveRequestForm.java +++ b/dotCMS/src/main/java/com/dotcms/rest/api/v1/drive/AbstractDriveRequestForm.java @@ -112,6 +112,30 @@ public interface AbstractDriveRequestForm { @Value.Default default boolean includeSystemHost(){return true;} + /** + * Which slice of content to list: the whole site, the site root alone, or System Host. + *

+ * Omitting it means today's behavior, exactly. It carries no default on purpose: at the + * site root an omitted scope and {@link BrowseScope#ALL} agree, but inside a folder they do + * not, and defaulting the field would silently turn every existing folder request recursive. + * That guarantee is what leaves the Asset Picker, which calls this same endpoint, untouched. + *

+ *

+ * Only valid with a site-root {@link #assetPath()}. The three scopes are things you can only + * be in at the top of a site; a folder is addressed by its path, so a scope named alongside a + * folder path is two contradictory statements and is refused rather than resolved. + *

+ *

+ * {@link #includeSystemHost()} is read only when this is {@code ALL} or omitted — the other + * two scopes already answer the System Host question themselves. + *

+ * + * @return the requested browse scope, or null for today's behavior + */ + @Nullable + @JsonProperty("browseScope") + BrowseScope browseScope(); + /** * List of language identifiers to include in the search. *

diff --git a/dotCMS/src/main/java/com/dotcms/rest/api/v1/drive/BrowseScope.java b/dotCMS/src/main/java/com/dotcms/rest/api/v1/drive/BrowseScope.java new file mode 100644 index 000000000000..03d8662759c9 --- /dev/null +++ b/dotCMS/src/main/java/com/dotcms/rest/api/v1/drive/BrowseScope.java @@ -0,0 +1,26 @@ +package com.dotcms.rest.api.v1.drive; + +/** + * Which slice of content a Content Drive listing is asked for. + * + *

Named in full — browse scope — because Content Drive separately carries a + * search scope that says which fields a text search reads. The two are independent: this + * one says where you are, the other says how a search reads what is there.

+ * + *

Absent is not a value here, and that is deliberate. A request that omits the browse + * scope keeps meaning exactly what it means today, which is what leaves the Asset Picker and every + * other caller of this endpoint untouched. In particular {@link #ALL} is not the default: it is + * meaningful only at the site root, and an omitted scope inside a folder is not the same thing as + * {@code ALL} inside a folder.

+ */ +public enum BrowseScope { + + /** The whole current site, at any depth. What the site root returns today. */ + ALL, + + /** Only the items that sit at the site root, not inside any folder. */ + ROOT, + + /** System Host content only. The site the request names is context, not a filter. */ + SYSTEM_HOST +} diff --git a/dotCMS/src/test/java/com/dotcms/rest/api/v1/drive/DriveRequestFormBrowseScopeTest.java b/dotCMS/src/test/java/com/dotcms/rest/api/v1/drive/DriveRequestFormBrowseScopeTest.java new file mode 100644 index 000000000000..f3cb261a18f5 --- /dev/null +++ b/dotCMS/src/test/java/com/dotcms/rest/api/v1/drive/DriveRequestFormBrowseScopeTest.java @@ -0,0 +1,94 @@ +package com.dotcms.rest.api.v1.drive; + +import static org.junit.Assert.assertTrue; +import static org.junit.Assert.assertThrows; + +import java.util.List; +import org.junit.Test; + +/** + * The invariant that keeps a browse scope and a folder path from contradicting each other. + * + *

The three browse scopes are things you can only be in at the top of a site: the whole site, + * the site root itself, and System Host. A folder is addressed by its path. So there is no + * combination of a folder path and a scope that means anything, and the request is refused rather + * than resolved by precedence.

+ * + *

Why refused and not resolved. The precedent is {@code BulkUploadForm}, which refuses a + * submission naming both a folder and a site with the same reasoning: a caller that says two + * contradictory things has not expressed a preference, and picking one would silently put content + * somewhere they did not choose. Ignoring the scope instead would be friendlier to a sloppy caller + * and would hide the caller's bug, which is the trade this test pins.

+ * + *

On Red. This test names a field the form does not have yet, so it cannot compile until + * the field is declared. The declaration is not the behavior under test: the behavior is the + * refusal. The honest Red state for this test is therefore "the field exists, the validation does + * not, and the assertion fails" — not a compile error. Confirm it in that state before writing the + * validation.

+ * + *

Needs no database.

+ */ +public class DriveRequestFormBrowseScopeTest { + + private static final String SITE_ROOT = "//demo.dotcms.com/"; + private static final String A_FOLDER = "//demo.dotcms.com/application/"; + + /** + * Every builder here pins {@code language}. Its declared default asks the language API for the + * default language, which reaches the database, so leaving it unset makes the build fail with + * "No Company!" before any validation runs — a failure that looks like a refusal but is only + * the bootstrap. Pinning it keeps the assertions about the browse scope. + */ + private static DriveRequestForm.Builder formFor(final String assetPath) { + return DriveRequestForm.builder().assetPath(assetPath).language(List.of("1")); + } + + /** + * Given a browse scope named together with a folder path, When the form is built, Then it is + * refused, and the message names both halves of the contradiction so the caller can see which + * two statements they made. + */ + @Test + public void testAnExplicitBrowseScopeWithAFolderPathIsRefused() { + for (final BrowseScope scope : BrowseScope.values()) { + // Deliberately not pinned to an exception type: where the refusal is raised is an + // implementation choice, and a test that names the type would decide it here. What is + // asserted is that the request does not succeed and that the reason is legible. + final RuntimeException refused = assertThrows( + "a folder path with " + scope + " must be refused, not resolved", + RuntimeException.class, + () -> formFor(A_FOLDER).browseScope(scope).build()); + + // The message assertions are what keep the broad type honest: an incidental + // NullPointerException carries no message and fails here rather than passing as a + // refusal that never happened. + final String message = String.valueOf(refused.getMessage()); + assertTrue("the refusal must name the scope, got: " + message, + message.contains(scope.name())); + assertTrue("the refusal must name the path, got: " + message, + message.contains("/application/")); + } + } + + /** + * Given each browse scope at the site root, When the form is built, Then it is accepted. This + * is the other half of the invariant: the refusal must be about the contradiction, not about + * the scope being present at all. + */ + @Test + public void testEveryBrowseScopeIsAcceptedAtTheSiteRoot() { + for (final BrowseScope scope : BrowseScope.values()) { + formFor(SITE_ROOT).browseScope(scope).build(); + } + } + + /** + * Given a folder path and no browse scope, When the form is built, Then it is accepted + * unchanged. This is the shape the Asset Picker sends and the reason the field has no default: + * a request that never mentions a scope must keep meaning exactly what it means today. + */ + @Test + public void testAFolderPathWithNoBrowseScopeIsUntouched() { + formFor(A_FOLDER).build(); + } +} From a2b6b8bc720eaa5c7766c6bfbb012654973c0ed9 Mon Sep 17 00:00:00 2001 From: Jalinson Diaz Date: Mon, 14 Sep 2026 10:59:58 -0300 Subject: [PATCH 10/44] =?UTF-8?q?feat(drive):=20green=20=E2=80=94=20refuse?= =?UTF-8?q?=20a=20browse=20scope=20named=20with=20a=20folder=20path=20(#37?= =?UTF-8?q?426)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The check splits the path rather than resolving it: it asks only whether anything follows the site, which needs no site lookup and no database. Resolving stays AssetPathResolver's job, after the request is known to be coherent. Two things the red run taught, both now in the test rather than in someone's afternoon. The form cannot be built at all in a unit test without pinning `language`, because its default asks the language API for the default language and fails with "No Company!" long before any validation runs — a failure that reads like a refusal but is only the bootstrap. And a JAX-RS exception carries its reason in the response the caller receives, not in getMessage(), which returns the status line; the test now reads the reason from wherever the refusal puts it, so it asserts that the caller is told what went wrong without deciding which exception the validation raises. Green: 14 tests across the new and pre-existing drive and browser classes. Co-Authored-By: Claude Opus 5 --- .../v1/drive/AbstractDriveRequestForm.java | 44 +++++++++++++++++++ .../DriveRequestFormBrowseScopeTest.java | 16 ++++++- 2 files changed, 59 insertions(+), 1 deletion(-) diff --git a/dotCMS/src/main/java/com/dotcms/rest/api/v1/drive/AbstractDriveRequestForm.java b/dotCMS/src/main/java/com/dotcms/rest/api/v1/drive/AbstractDriveRequestForm.java index e7e9525fd330..9f69aea5dd9f 100644 --- a/dotCMS/src/main/java/com/dotcms/rest/api/v1/drive/AbstractDriveRequestForm.java +++ b/dotCMS/src/main/java/com/dotcms/rest/api/v1/drive/AbstractDriveRequestForm.java @@ -1,6 +1,9 @@ package com.dotcms.rest.api.v1.drive; +import com.dotcms.rest.exception.BadRequestException; import com.dotmarketing.business.APILocator; +import com.dotmarketing.util.HostUtil; +import com.liferay.util.StringPool; import com.fasterxml.jackson.annotation.JsonProperty; import com.fasterxml.jackson.databind.annotation.JsonDeserialize; import com.fasterxml.jackson.databind.annotation.JsonSerialize; @@ -136,6 +139,47 @@ public interface AbstractDriveRequestForm { @JsonProperty("browseScope") BrowseScope browseScope(); + /** + * A browse scope is only meaningful at the site root, so one named alongside a folder path is + * refused. + *

+ * Refused rather than resolved by precedence. A caller that names both has said two + * contradictory things, and honouring either one would quietly list somewhere they did not + * ask for. This follows {@code BulkUploadForm}, which refuses a submission naming both a + * folder and a site for the same reason. Ignoring the scope instead would be kinder to a + * sloppy caller and would hide the caller's bug. + *

+ *

+ * The path is split here rather than resolved: this asks only whether anything follows the + * site, which needs no site lookup and no database. Resolving the path is + * {@code AssetPathResolver}'s job and happens later, once the request is known to be coherent. + *

+ */ + @Value.Check + default void checkBrowseScopeIsAtTheSiteRoot() { + if (null == browseScope()) { + return; + } + final String pathWithinSite = pathWithinSite(assetPath()); + if (!pathWithinSite.isEmpty() && !StringPool.FORWARD_SLASH.equals(pathWithinSite)) { + throw new BadRequestException(String.format( + "browseScope is only valid at the site root; got '%s' with path '%s'", + browseScope().name(), pathWithinSite)); + } + } + + /** + * The portion of {@code //site/some/path/} that follows the site, or an empty string when the + * path names a site and nothing else. + */ + static String pathWithinSite(final String assetPath) { + final String withoutHostIndicator = assetPath.startsWith(HostUtil.HOST_INDICATOR) + ? assetPath.substring(HostUtil.HOST_INDICATOR.length()) + : assetPath; + final int firstSlash = withoutHostIndicator.indexOf(StringPool.FORWARD_SLASH); + return firstSlash < 0 ? StringPool.BLANK : withoutHostIndicator.substring(firstSlash); + } + /** * List of language identifiers to include in the search. *

diff --git a/dotCMS/src/test/java/com/dotcms/rest/api/v1/drive/DriveRequestFormBrowseScopeTest.java b/dotCMS/src/test/java/com/dotcms/rest/api/v1/drive/DriveRequestFormBrowseScopeTest.java index f3cb261a18f5..b28678f47726 100644 --- a/dotCMS/src/test/java/com/dotcms/rest/api/v1/drive/DriveRequestFormBrowseScopeTest.java +++ b/dotCMS/src/test/java/com/dotcms/rest/api/v1/drive/DriveRequestFormBrowseScopeTest.java @@ -4,6 +4,7 @@ import static org.junit.Assert.assertThrows; import java.util.List; +import javax.ws.rs.WebApplicationException; import org.junit.Test; /** @@ -62,7 +63,7 @@ public void testAnExplicitBrowseScopeWithAFolderPathIsRefused() { // The message assertions are what keep the broad type honest: an incidental // NullPointerException carries no message and fails here rather than passing as a // refusal that never happened. - final String message = String.valueOf(refused.getMessage()); + final String message = reasonGivenToTheCaller(refused); assertTrue("the refusal must name the scope, got: " + message, message.contains(scope.name())); assertTrue("the refusal must name the path, got: " + message, @@ -70,6 +71,19 @@ public void testAnExplicitBrowseScopeWithAFolderPathIsRefused() { } } + /** + * The reason a refusal gives the caller, read from wherever the refusal carries it. A JAX-RS + * exception puts it in the response the caller receives rather than in {@code getMessage()}, + * which returns only the status line; anything else is read the ordinary way. Written this way + * so the assertion is about the caller being told what went wrong, not about which exception + * the validation happens to raise. + */ + private static String reasonGivenToTheCaller(final RuntimeException refused) { + return refused instanceof WebApplicationException + ? String.valueOf(((WebApplicationException) refused).getResponse().getEntity()) + : String.valueOf(refused.getMessage()); + } + /** * Given each browse scope at the site root, When the form is built, Then it is accepted. This * is the other half of the invariant: the refusal must be about the contradiction, not about From 014fa0806b9e8402bbc64e5769661207ec23b751 Mon Sep 17 00:00:00 2001 From: Jalinson Diaz Date: Mon, 14 Sep 2026 11:52:57 -0300 Subject: [PATCH 11/44] refactor(browser): three states for System Host, where a flag said two (#37426) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The host predicate has three shapes and the boolean could only name two. ONLY is the one it could never say, and it is what lets Content Drive browse shared content on its own. EXCLUDE is the default because it reproduces exactly what `false` produced: several callers reach this builder without ever mentioning System Host, so any other default would quietly change what all of them return. Behavior is unchanged. The Content Drive chip still sends a boolean — include or exclude is all it can meaningfully say — and the helper maps it. The Lucene builder keeps its system-folder quirk for now, marked in place: it makes that builder disagree with the SQL one about a structural criterion, which is a defect of its own and gets fixed with its own test rather than silently here. Before touching any of it, the guard gained its other half: that asking for shared content widens the predicate. Untested anywhere until now, and the refactor passes straight through it, so a broken "chip on" would have shipped in silence with every existing test still green. The guard needed no edits to survive: 30 insertions, zero deletions. 27 tests green across the browser and drive classes. Co-Authored-By: Claude Opus 5 --- .../com/dotcms/browser/BrowserAPIImpl.java | 23 ++++++++++---- .../java/com/dotcms/browser/BrowserQuery.java | 29 +++++++++++------- .../com/dotcms/browser/SystemHostMode.java | 26 ++++++++++++++++ .../rest/api/v1/drive/ContentDriveHelper.java | 5 +++- .../browser/BrowserQueryHostClauseTest.java | 30 +++++++++++++++++++ 5 files changed, 95 insertions(+), 18 deletions(-) create mode 100644 dotCMS/src/main/java/com/dotcms/browser/SystemHostMode.java diff --git a/dotCMS/src/main/java/com/dotcms/browser/BrowserAPIImpl.java b/dotCMS/src/main/java/com/dotcms/browser/BrowserAPIImpl.java index 7adf21d5a477..8ee68b8ca150 100644 --- a/dotCMS/src/main/java/com/dotcms/browser/BrowserAPIImpl.java +++ b/dotCMS/src/main/java/com/dotcms/browser/BrowserAPIImpl.java @@ -643,7 +643,13 @@ private String buildPureESQuery(final BrowserQuery browserQuery) { ? browserQuery.site.getIdentifier() : browserQuery.folder.getHostId(); - if (browserQuery.forceSystemHost || browserQuery.folder.isSystemFolder()) { + if (SystemHostMode.ONLY == browserQuery.systemHostMode) { + query.append("+conhost:SYSTEM_HOST "); + } else if (SystemHostMode.INCLUDE == browserQuery.systemHostMode + || browserQuery.folder.isSystemFolder()) { + // The system-folder half of this condition is deliberately preserved for now: it makes + // this builder disagree with the SQL one about a structural criterion, which is a + // defect in its own right and is fixed with its own test rather than silently here. query.append("+(conhost:").append(hostId).append(" OR conhost:SYSTEM_HOST) "); } else { query.append("+conhost:").append(hostId).append(" "); @@ -2034,9 +2040,9 @@ private SelectQuery selectQuery(final BrowserQuery browserQuery) { if (shouldApplySiteFiltering) { if (browserQuery.site != null) { appendSiteQuery(selectQuery, browserQuery.site.getIdentifier(), - browserQuery.forceSystemHost, parameters); + browserQuery.systemHostMode, parameters); } else { - if (browserQuery.forceSystemHost) { + if (SystemHostMode.EXCLUDE != browserQuery.systemHostMode) { appendSystemHostQuery(selectQuery); } } @@ -2195,9 +2201,14 @@ private void appendLanguageQuery(StringBuilder sqlQuery, Set languageIds, * @param siteIdentifier The site identifier to filter by. * @param parameters The list of parameters to add the site identifier to. */ - private void appendSiteQuery(StringBuilder sqlQuery, String siteIdentifier, boolean forceSystemHost, - List parameters) { - if(forceSystemHost){ + private void appendSiteQuery(StringBuilder sqlQuery, String siteIdentifier, + SystemHostMode systemHostMode, List parameters) { + if (SystemHostMode.ONLY == systemHostMode) { + // The site is context rather than a filter here, so nothing is bound. + appendSystemHostQuery(sqlQuery); + return; + } + if (SystemHostMode.INCLUDE == systemHostMode) { sqlQuery.append(" and (id.host_inode = ? or id.host_inode = 'SYSTEM_HOST') "); } else { sqlQuery.append(" and (id.host_inode = ?) "); diff --git a/dotCMS/src/main/java/com/dotcms/browser/BrowserQuery.java b/dotCMS/src/main/java/com/dotcms/browser/BrowserQuery.java index eaf909a60ede..217dca3f269e 100644 --- a/dotCMS/src/main/java/com/dotcms/browser/BrowserQuery.java +++ b/dotCMS/src/main/java/com/dotcms/browser/BrowserQuery.java @@ -70,7 +70,7 @@ public class BrowserQuery { final Set contentTypeIds; final Set excludedContentTypeIds; final Host site; - final boolean forceSystemHost; + final SystemHostMode systemHostMode; final boolean skipFolder; final boolean ignoreSiteForFolders; final Folder folder; @@ -130,7 +130,7 @@ public String toString() { ", contentCursor=" + contentCursor + ", folderCursor=" + folderCursor + ", linkCursor=" + linkCursor + " ,site:" + site + ", folder:" + folder + ", filter:" - + filter + ", sortBy:" + sortBy + ", forceSystemHost:" + forceSystemHost + + filter + ", sortBy:" + sortBy + ", systemHostMode:" + systemHostMode + ", skipFolder:" + skipFolder + ", ignoreSiteForFolders:" + ignoreSiteForFolders + ", offset:" + offset + ", maxResults:" + maxResults + ", showWorking:" + showWorking + ", showArchived:" @@ -200,8 +200,9 @@ private BrowserQuery(final Builder builder) { this.showMenuItemsOnly = builder.showMenuItemsOnly; this.site = siteAndFolder._1; this.folder = siteAndFolder._2; - //Despite the site and folder passed, forceSystemHost makes the inclusion of SYSTEM_HOME in the query - this.forceSystemHost = builder.forceSystemHost; + //Despite the site and folder passed, this decides whether SYSTEM_HOST content joins the + //results, is kept out of them, or is the only thing in them. + this.systemHostMode = builder.systemHostMode; this.directParent = this.folder.isSystemFolder() ? site : folder; this.roles= Try.of(()->APILocator.getRoleAPI().loadRolesForUser(user.getUserId()).toArray(new Role[0])).getOrElse(new Role[0]); } @@ -312,7 +313,7 @@ public static final class Builder { private final StringBuilder luceneQuery = new StringBuilder(); private final Set baseTypes = new HashSet<>(); private String hostFolderId = FolderAPI.SYSTEM_FOLDER; - private boolean forceSystemHost = false; + private SystemHostMode systemHostMode = SystemHostMode.EXCLUDE; private boolean skipFolder = false; private boolean ignoreSiteForFolders = false; private String hostIdSystemFolder = null; @@ -359,7 +360,7 @@ private Builder(BrowserQuery browserQuery) { ? browserQuery.site.getIdentifier() : browserQuery.folder.getInode(); this.useElasticsearchFiltering = browserQuery.useElasticsearchFiltering; - this.forceSystemHost = browserQuery.forceSystemHost; + this.systemHostMode = browserQuery.systemHostMode; this.skipFolder = browserQuery.skipFolder; this.ignoreSiteForFolders = browserQuery.ignoreSiteForFolders; this.filter = browserQuery.filter; @@ -440,12 +441,18 @@ public Builder withHostOrFolderId(@Nonnull String hostFolderId) { } /** - * When set, search includes items that belong to system-host - * @param forceSystemHost - * @return + * What the search does about System Host content: keeps it out, admits it alongside the + * named site, or returns nothing else. + *

+ * Replaces a boolean that could only say the first two. Left unset it is + * {@link SystemHostMode#EXCLUDE}, which is what the boolean {@code false} meant, so a + * caller that never mentions System Host is unaffected. + * + * @param systemHostMode how System Host content is treated, never null + * @return this builder */ - public Builder forceSystemHost(boolean forceSystemHost) { - this.forceSystemHost = forceSystemHost; + public Builder systemHostMode(@Nonnull SystemHostMode systemHostMode) { + this.systemHostMode = systemHostMode; return this; } diff --git a/dotCMS/src/main/java/com/dotcms/browser/SystemHostMode.java b/dotCMS/src/main/java/com/dotcms/browser/SystemHostMode.java new file mode 100644 index 000000000000..96dba3f34fc6 --- /dev/null +++ b/dotCMS/src/main/java/com/dotcms/browser/SystemHostMode.java @@ -0,0 +1,26 @@ +package com.dotcms.browser; + +/** + * What a listing does about System Host content. + * + *

Three states rather than a flag, because the host predicate has three shapes and a boolean + * can only name two of them. {@link #ONLY} is the one it could never say, and it is what lets + * Content Drive browse shared content on its own.

+ * + *

{@link #EXCLUDE} is the default, and that is load-bearing. It reproduces exactly what + * the boolean {@code false} produced before this existed. Several callers reach this builder + * without ever mentioning System Host — the assets API, the older file browser and its deprecated + * tree endpoint, the legacy admin browser, a Velocity viewtool, and two internal callers — so any + * other default would silently change what all of them return.

+ */ +public enum SystemHostMode { + + /** The named site only. What a caller that says nothing about System Host gets. */ + EXCLUDE, + + /** The named site, plus System Host alongside it. */ + INCLUDE, + + /** System Host alone. The named site becomes context rather than a filter. */ + ONLY +} diff --git a/dotCMS/src/main/java/com/dotcms/rest/api/v1/drive/ContentDriveHelper.java b/dotCMS/src/main/java/com/dotcms/rest/api/v1/drive/ContentDriveHelper.java index e4518dfc03a1..5d266a58cd98 100644 --- a/dotCMS/src/main/java/com/dotcms/rest/api/v1/drive/ContentDriveHelper.java +++ b/dotCMS/src/main/java/com/dotcms/rest/api/v1/drive/ContentDriveHelper.java @@ -5,6 +5,7 @@ import com.dotcms.browser.BrowserQuery; import com.dotcms.browser.BrowserQuery.Builder; import com.dotcms.browser.ContentStatus; +import com.dotcms.browser.SystemHostMode; import com.dotcms.browser.FieldSearchCriteria; import com.dotcms.rest.exception.BadRequestException; import com.dotcms.contenttype.business.ContentTypeAPI; @@ -174,7 +175,9 @@ public PaginatedContents driveSearch(final DriveRequestForm requestForm, final U .ignoreSiteForFolders(true); } //This ensures that despite the site passed systemHost will be included too - builder.forceSystemHost(requestForm.includeSystemHost()); + builder.systemHostMode(requestForm.includeSystemHost() + ? SystemHostMode.INCLUDE + : SystemHostMode.EXCLUDE); // Enable Elasticsearch filtering for text search when filter is provided if (null != requestForm.filters() && UtilMethods.isSet(requestForm.filters().text())) { diff --git a/dotCMS/src/test/java/com/dotcms/browser/BrowserQueryHostClauseTest.java b/dotCMS/src/test/java/com/dotcms/browser/BrowserQueryHostClauseTest.java index 1fc94adbfe7b..414c97ce3ecb 100644 --- a/dotCMS/src/test/java/com/dotcms/browser/BrowserQueryHostClauseTest.java +++ b/dotCMS/src/test/java/com/dotcms/browser/BrowserQueryHostClauseTest.java @@ -75,11 +75,39 @@ public void testTheSitePredicateIsNotWidenedByAnOr() throws Exception { hostClause.toUpperCase().contains(" OR ")); } + /** + * The other half of the same guarantee: when the caller asks for shared content — which is + * what the "Show System Host" chip sends, on by default — the site predicate widens to admit + * System Host as well. + *

+ * Untested anywhere before this. The refactor that gives this flag a third state passes + * through here, so without this a broken "chip on" would ship in silence: shared assets would + * simply stop appearing, with every existing test still green. + */ + @Test + public void testAskingForSharedContentWidensThePredicateToAdmitSystemHost() throws Exception { + final SelectResult result = buildSelect(true); + + assertTrue("the site is still matched", result.sql.contains("id.host_inode = ?")); + assertTrue("and System Host is admitted alongside it", + result.sql.contains("id.host_inode = 'SYSTEM_HOST'")); + assertEquals("the site identifier is still the only bound value", SITE_ID, + String.valueOf(result.params.get(0))); + } + /** * Builds the statement for a query that names a site and a folder and nothing else, which is * the default shape of every non-Content-Drive caller. */ private static SelectResult buildSelect() throws Exception { + return buildSelect(false); + } + + /** + * @param askForSharedContent what the caller says about System Host. This is the one line the + * three-state refactor changes in this file; the assertions above it must not move. + */ + private static SelectResult buildSelect(final boolean askForSharedContent) throws Exception { final BrowserAPIImpl api = mock(BrowserAPIImpl.class, CALLS_REAL_METHODS); final BrowserQuery query = mock(BrowserQuery.class, CALLS_REAL_METHODS); @@ -91,6 +119,8 @@ private static SelectResult buildSelect() throws Exception { setField(query, "baseTypes", Set.of(BaseContentType.ANY)); setField(query, "fieldCriteria", List.of()); setField(query, "mimeTypes", List.of()); + setField(query, "systemHostMode", + askForSharedContent ? SystemHostMode.INCLUDE : SystemHostMode.EXCLUDE); // Both are mocked rather than constructed: `new Host()` resolves its content type through // the legacy cache, which calls APILocator.systemUser() and reaches for a database From b01006c15028a38b7847e291b8a8592d9372d10e Mon Sep 17 00:00:00 2001 From: Jalinson Diaz Date: Mon, 14 Sep 2026 11:56:53 -0300 Subject: [PATCH 12/44] feat(drive): the site root stops meaning the whole site (#37426) Two decisions, written as pure functions so they can be pinned without resolving a path, which needs a site lookup and a database. The listing is exercised end to end by integration tests; what the unit tests assert is the decision itself. Whether the folder constraint applies is the entire difference between the two scopes: at the root the folder to constrain by is the system folder, whose path is already "/", so dropping it gives the whole site and keeping it gives what sits at the root. Which host clause is emitted is the other: the chip decides only where it has something to decide, and the two narrow scopes already answer the question it asks, so a stale chip value on the request cannot override them. The first line of the folder decision is that a request naming a folder is never recursive, whatever else it says. That is why this is a function rather than an inline condition: the Asset Picker addresses folders by path and never names a scope, so "all" implemented as unconditional folder-skipping would have turned every one of its folder requests into a listing of every descendant. Red first, from deliberately neutral stubs: 4 of 9 failing on behavior, none on compilation. Green now, and 41 tests across the browser and drive classes. Co-Authored-By: Claude Opus 5 --- .../rest/api/v1/drive/ContentDriveHelper.java | 60 +++++++- .../ContentDriveHelperBrowseScopeTest.java | 130 ++++++++++++++++++ 2 files changed, 183 insertions(+), 7 deletions(-) create mode 100644 dotCMS/src/test/java/com/dotcms/rest/api/v1/drive/ContentDriveHelperBrowseScopeTest.java diff --git a/dotCMS/src/main/java/com/dotcms/rest/api/v1/drive/ContentDriveHelper.java b/dotCMS/src/main/java/com/dotcms/rest/api/v1/drive/ContentDriveHelper.java index 5d266a58cd98..aa34ad78dbfa 100644 --- a/dotCMS/src/main/java/com/dotcms/rest/api/v1/drive/ContentDriveHelper.java +++ b/dotCMS/src/main/java/com/dotcms/rest/api/v1/drive/ContentDriveHelper.java @@ -163,11 +163,12 @@ public PaginatedContents driveSearch(final DriveRequestForm requestForm, final U .sortByDesc(sortDesc); // Determine if we're requesting from a specific folder or host root - if (folder.isSystemFolder()) { + final boolean atSiteRoot = folder.isSystemFolder(); + if (atSiteRoot) { builder.withHostOrFolderId(host.getIdentifier()) - /// if we're setting a site-name directly, we care fore all subfolders - /// Therefore, we should skip setting a folder path - .skipFolder(true); + /// Whether the folder path is applied is what separates "the whole site" from + /// "what sits at its root"; the system folder's path is already "/". + .skipFolder(skipsFolderConstraint(requestForm.browseScope(), atSiteRoot)); } else { builder.withHostOrFolderId(folder.getInode()) // When a specific folder is selected, enable ignoreSiteForFolders to allow @@ -175,9 +176,8 @@ public PaginatedContents driveSearch(final DriveRequestForm requestForm, final U .ignoreSiteForFolders(true); } //This ensures that despite the site passed systemHost will be included too - builder.systemHostMode(requestForm.includeSystemHost() - ? SystemHostMode.INCLUDE - : SystemHostMode.EXCLUDE); + builder.systemHostMode( + systemHostModeFor(requestForm.browseScope(), requestForm.includeSystemHost())); // Enable Elasticsearch filtering for text search when filter is provided if (null != requestForm.filters() && UtilMethods.isSet(requestForm.filters().text())) { @@ -263,6 +263,52 @@ public PaginatedContents driveSearch(final DriveRequestForm requestForm, final U return browserAPI.getPaginatedContents(browserQuery); } + /** + * Whether the folder constraint is dropped, so the listing spans every depth of the site + * instead of the level it names. + *

+ * A request that names a folder is never recursive, whatever else it says. That is the + * first line of this method and the reason it is written as a decision rather than inlined: + * the Asset Picker addresses folders by path and never names a scope, so making the + * all-site-content scope mean "always skip the folder" would silently turn every one of its + * folder requests into a listing of every descendant. + *

+ * At the site root the folder to constrain by is the system folder, whose path is already + * {@code /}. So dropping the constraint gives the whole site, and keeping it gives what sits + * at the root — which is the entire difference between the two scopes. + * + * @param browseScope the requested scope, or null for today's behavior + * @param atSiteRoot whether the resolved folder is the site's root + */ + static boolean skipsFolderConstraint(final BrowseScope browseScope, final boolean atSiteRoot) { + if (!atSiteRoot) { + return false; + } + return null == browseScope || BrowseScope.ALL == browseScope; + } + + /** + * Which host clause the listing emits. + *

+ * The chip decides only where it has something to decide. The site-root scope excludes System + * Host and the System Host scope returns nothing else, so in both the scope already answers + * the question the chip asks, and a stale chip value carried on the request cannot override + * it. That is also why the chip is offered in one scope and disabled in the rest. + * + * @param browseScope the requested scope, or null for today's behavior + * @param includeSystemHost what the "Show System Host" chip sent + */ + static SystemHostMode systemHostModeFor(final BrowseScope browseScope, + final boolean includeSystemHost) { + if (BrowseScope.SYSTEM_HOST == browseScope) { + return SystemHostMode.ONLY; + } + if (BrowseScope.ROOT == browseScope) { + return SystemHostMode.EXCLUDE; + } + return includeSystemHost ? SystemHostMode.INCLUDE : SystemHostMode.EXCLUDE; + } + /** * if base types include FILEASSET then we pass the respective parameter as true * @param baseTypes base types diff --git a/dotCMS/src/test/java/com/dotcms/rest/api/v1/drive/ContentDriveHelperBrowseScopeTest.java b/dotCMS/src/test/java/com/dotcms/rest/api/v1/drive/ContentDriveHelperBrowseScopeTest.java new file mode 100644 index 000000000000..ad382ecbaad2 --- /dev/null +++ b/dotCMS/src/test/java/com/dotcms/rest/api/v1/drive/ContentDriveHelperBrowseScopeTest.java @@ -0,0 +1,130 @@ +package com.dotcms.rest.api.v1.drive; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +import com.dotcms.browser.SystemHostMode; +import org.junit.Test; + +/** + * What a browse scope decides: whether the folder constraint is applied, and which host clause is + * emitted. + * + *

Both are expressed as pure functions so they can be pinned without resolving a path, which + * needs a site lookup and a database. The listing itself is exercised end to end by the + * integration tests; what is asserted here is the decision, case by case.

+ * + *

The case that matters most is the one about no scope at all inside a folder. The Asset + * Picker calls this same endpoint and addresses folders by path without ever naming a scope. If + * "all" were ever implemented as unconditional folder-skipping, those requests would silently + * start returning every descendant, and nothing else in the suite would notice.

+ */ +public class ContentDriveHelperBrowseScopeTest { + + private static final boolean AT_THE_SITE_ROOT = true; + private static final boolean INSIDE_A_FOLDER = false; + private static final boolean CHIP_ON = true; + private static final boolean CHIP_OFF = false; + + // ---- which folder constraint applies ------------------------------------------------- + + /** + * Given no browse scope, When the request is at the site root, Then the folder constraint is + * dropped — the whole site at any depth, which is what the site root returns today. + */ + @Test + public void testNoScopeAtTheSiteRootStillListsTheWholeSite() { + assertTrue("today's site-root behavior must survive untouched", + ContentDriveHelper.skipsFolderConstraint(null, AT_THE_SITE_ROOT)); + } + + /** + * Given no browse scope, When the request names a folder, Then the folder constraint applies. + * This is the Asset Picker's shape, and the regression this whole design is arranged around. + */ + @Test + public void testNoScopeInsideAFolderStaysInThatFolder() { + assertFalse("a folder request must never become recursive", + ContentDriveHelper.skipsFolderConstraint(null, INSIDE_A_FOLDER)); + } + + /** + * Given the all-site-content scope at the root, Then it decides exactly what no scope decides + * there. Saying it explicitly and leaving it unsaid must not differ. + */ + @Test + public void testAllSiteContentAtTheRootMatchesSayingNothing() { + assertEquals("the explicit spelling must agree with the implicit one", + ContentDriveHelper.skipsFolderConstraint(null, AT_THE_SITE_ROOT), + ContentDriveHelper.skipsFolderConstraint(BrowseScope.ALL, AT_THE_SITE_ROOT)); + } + + /** + * Given the site-root scope, Then the folder constraint applies, which is the whole point of + * it: the system folder's path is already {@code /}, so applying it lists what sits at the + * root rather than everything beneath it. + */ + @Test + public void testTheSiteRootScopeAppliesTheFolderConstraint() { + assertFalse("the root scope must constrain to the root", + ContentDriveHelper.skipsFolderConstraint(BrowseScope.ROOT, AT_THE_SITE_ROOT)); + } + + /** + * Given the System Host scope, Then the folder constraint applies too. System Host has no + * folders, so everything in it sits at its root. + */ + @Test + public void testTheSystemHostScopeAppliesTheFolderConstraint() { + assertFalse("System Host content all sits at its root", + ContentDriveHelper.skipsFolderConstraint(BrowseScope.SYSTEM_HOST, AT_THE_SITE_ROOT)); + } + + // ---- which host clause is emitted ---------------------------------------------------- + + /** + * Given no browse scope, Then the chip decides, exactly as it does today. + */ + @Test + public void testWithNoScopeTheChipDecides() { + assertEquals(SystemHostMode.INCLUDE, ContentDriveHelper.systemHostModeFor(null, CHIP_ON)); + assertEquals(SystemHostMode.EXCLUDE, ContentDriveHelper.systemHostModeFor(null, CHIP_OFF)); + } + + /** + * Given the all-site-content scope, Then the chip still decides. This is the only scope where + * the chip has anything to say, which is why it is the only one where it is offered. + */ + @Test + public void testInAllSiteContentTheChipStillDecides() { + assertEquals(SystemHostMode.INCLUDE, + ContentDriveHelper.systemHostModeFor(BrowseScope.ALL, CHIP_ON)); + assertEquals(SystemHostMode.EXCLUDE, + ContentDriveHelper.systemHostModeFor(BrowseScope.ALL, CHIP_OFF)); + } + + /** + * Given the site-root scope, Then System Host is excluded whatever the chip says. The chip is + * disabled there, but a request can still carry a stale value, and the scope must win. + */ + @Test + public void testTheSiteRootScopeExcludesSystemHostWhateverTheChipSays() { + assertEquals(SystemHostMode.EXCLUDE, + ContentDriveHelper.systemHostModeFor(BrowseScope.ROOT, CHIP_ON)); + assertEquals(SystemHostMode.EXCLUDE, + ContentDriveHelper.systemHostModeFor(BrowseScope.ROOT, CHIP_OFF)); + } + + /** + * Given the System Host scope, Then System Host is the only thing listed, whatever the chip + * says. Same reasoning as the root scope: the scope already answers the question the chip asks. + */ + @Test + public void testTheSystemHostScopeReturnsSystemHostAloneWhateverTheChipSays() { + assertEquals(SystemHostMode.ONLY, + ContentDriveHelper.systemHostModeFor(BrowseScope.SYSTEM_HOST, CHIP_ON)); + assertEquals(SystemHostMode.ONLY, + ContentDriveHelper.systemHostModeFor(BrowseScope.SYSTEM_HOST, CHIP_OFF)); + } +} From 88805e9e397b4ba2378e45c865671f0006f9ea1b Mon Sep 17 00:00:00 2001 From: Jalinson Diaz Date: Mon, 14 Sep 2026 12:07:05 -0300 Subject: [PATCH 13/44] feat(dotcms-ui): one URL value becomes the two the endpoint expects (#37426) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The sidebar can select four things and the URL carries one value for all of them: absent is all site content, "/" is the site root, a deeper path is that folder, and a reserved word is System Host. One value is what stops a location and a scope disagreeing, and the mapping now lives in one function instead of being interpolated into the path — pasting the location in yields "//demo.dotcms.comSYSTEM_HOST" for the reserved word, which resolves to nothing. There is a test whose only job is that string. A folder deliberately gets no scope. It is addressed by its path, the endpoint refuses a scope alongside one, and a scope there is what would turn the listing into every descendant. Five existing tests went red, and they were right to. They asserted folders appear while browsing with no location, which is now all site content, where folders are not results. Four were about a filter not tearing down the structure being navigated, so they moved to the site root where structure exists — flipping their assertions would have deleted what they were checking. The fifth asserts the default request shape, and that default genuinely changed. 154 tests pass. Typecheck clean for the files touched; the errors it reports are pre-existing and elsewhere. Co-Authored-By: Claude Opus 5 --- .../src/lib/dot-content-drive.model.ts | 15 +++ .../portlet/src/lib/shared/constants.ts | 10 ++ .../lib/store/dot-content-drive.store.spec.ts | 126 +++++++++++++++++- .../src/lib/store/dot-content-drive.store.ts | 13 +- .../portlet/src/lib/utils/functions.ts | 49 +++++++ 5 files changed, 205 insertions(+), 8 deletions(-) diff --git a/core-web/libs/dotcms-models/src/lib/dot-content-drive.model.ts b/core-web/libs/dotcms-models/src/lib/dot-content-drive.model.ts index 4c5bff824a00..35e9402344ba 100644 --- a/core-web/libs/dotcms-models/src/lib/dot-content-drive.model.ts +++ b/core-web/libs/dotcms-models/src/lib/dot-content-drive.model.ts @@ -165,6 +165,12 @@ export interface DotContentDriveQueryFilters { text: string; } +/** + * Which slice of content a Content Drive listing is asked for: the whole current site at any + * depth, only what sits at the site root, or System Host alone. + */ +export type DotContentDriveBrowseScope = 'ALL' | 'ROOT' | 'SYSTEM_HOST'; + /** * Request body for the /api/v1/drive/search endpoint. * @@ -201,6 +207,15 @@ export interface DotContentDriveSearchRequest { */ includeSystemHost?: boolean; + /** + * Which slice of content to list. Omitting it means today's behavior, and it carries no + * default for that reason: at the site root an omitted scope and `ALL` agree, but inside a + * folder they do not, so defaulting it would turn folder requests into listings of every + * descendant. Only valid with a site-root `assetPath`; naming one alongside a folder path is + * refused by the endpoint. + */ + browseScope?: DotContentDriveBrowseScope; + /** * List of language identifiers to include in the search. * Supports both language codes (e.g., "en", "es") and language IDs. diff --git a/core-web/libs/portlets/dot-content-drive/portlet/src/lib/shared/constants.ts b/core-web/libs/portlets/dot-content-drive/portlet/src/lib/shared/constants.ts index 1de8832a402d..2a2534265928 100644 --- a/core-web/libs/portlets/dot-content-drive/portlet/src/lib/shared/constants.ts +++ b/core-web/libs/portlets/dot-content-drive/portlet/src/lib/shared/constants.ts @@ -74,6 +74,16 @@ export const DEFAULT_PATH = undefined; */ export const ROOT_PATH = '/'; +/** + * The location value that means System Host rather than a place inside the current site. + * + * A reserved word can never be mistaken for a folder, because every real path begins with `/` and + * this does not. That is what lets one value in the URL say all four things the sidebar can + * select — absent for all site content, `/` for the site root, a path for a folder, and this — + * without a second value beside it that could disagree. + */ +export const SYSTEM_HOST_PATH = 'SYSTEM_HOST'; + export const DEFAULT_PAGE: DotContentDrivePage = { hasMoreContent: true, hasMoreFolders: true, diff --git a/core-web/libs/portlets/dot-content-drive/portlet/src/lib/store/dot-content-drive.store.spec.ts b/core-web/libs/portlets/dot-content-drive/portlet/src/lib/store/dot-content-drive.store.spec.ts index 2f177a961fec..a853ce8efc34 100644 --- a/core-web/libs/portlets/dot-content-drive/portlet/src/lib/store/dot-content-drive.store.spec.ts +++ b/core-web/libs/portlets/dot-content-drive/portlet/src/lib/store/dot-content-drive.store.spec.ts @@ -47,6 +47,7 @@ import { DEFAULT_PAGINATION, DEFAULT_PATH, DEFAULT_SORT, + ROOT_PATH, DEFAULT_TREE_EXPANDED, SHARED_ASSETS_DISABLED_VALUE, SHARED_ASSETS_ENABLED_VALUE, @@ -267,10 +268,12 @@ describe('DotContentDriveStore', () => { it('should still show folders when a language is selected', () => { // Folders have no language, so a locale filter — which selects a *version* of content — - // must not tear down the structure being navigated. + // must not tear down the structure being navigated. Asserted at the site root, which is + // where structure exists: all site content asks for no folders by design, so testing it + // there would prove nothing about the language filter. store.initContentDrive({ currentSite: SYSTEM_HOST, - path: DEFAULT_PATH, + path: ROOT_PATH, filters: { languageId: ['1', '2'] }, isTreeExpanded: false }); @@ -311,7 +314,10 @@ describe('DotContentDriveStore', () => { // Likewise `status`: omitted entirely when nothing is selected, so an unfiltered // request stays byte-identical to one that never knew about the filter (FR-002). expect(request.status).toBeUndefined(); - expect(request.showFolders).toBe(true); + // No location means all site content, which spans every folder in the site and so + // lists none of them; the tree is still there to navigate. Browsing the site root + // instead is what asks for the top-level folders. + expect(request.showFolders).toBe(false); }); describe('includeSystemHost', () => { @@ -397,6 +403,109 @@ describe('DotContentDriveStore', () => { expect(request.assetPath).toBe(`//${customSite.hostname}/`); }); + describe('browse scope', () => { + // The wire values are written out rather than imported: this is the one place the + // client's idea of a location turns into what the endpoint is asked for, so the + // assertions should fail if that mapping drifts, not follow it. + + it('should ask for all site content when the URL carries no location', () => { + store.initContentDrive({ + currentSite: SYSTEM_HOST, + path: DEFAULT_PATH, + filters: {}, + isTreeExpanded: false + }); + + const request = store.$request(); + + expect(request.browseScope).toBe('ALL'); + expect(request.assetPath).toBe(`//${SYSTEM_HOST.hostname}/`); + }); + + it('should ask for the site root when the location is the root itself', () => { + store.initContentDrive({ + currentSite: SYSTEM_HOST, + path: '/', + filters: {}, + isTreeExpanded: false + }); + + const request = store.$request(); + + expect(request.browseScope).toBe('ROOT'); + expect(request.assetPath).toBe(`//${SYSTEM_HOST.hostname}/`); + }); + + it('should name no scope when the location is a folder', () => { + store.initContentDrive({ + currentSite: SYSTEM_HOST, + path: '/documents/', + filters: {}, + isTreeExpanded: false + }); + + const request = store.$request(); + + // A folder is addressed by its path alone. Naming a scope as well would be + // refused by the endpoint, and it is what would turn this into a listing of + // every descendant. + expect(request.browseScope).toBeUndefined(); + expect(request.assetPath).toBe(`//${SYSTEM_HOST.hostname}/documents/`); + }); + + it('should ask for System Host without pasting the reserved word onto the site', () => { + store.initContentDrive({ + currentSite: SYSTEM_HOST, + path: 'SYSTEM_HOST', + filters: {}, + isTreeExpanded: false + }); + + const request = store.$request(); + + expect(request.browseScope).toBe('SYSTEM_HOST'); + // The bug this exists to catch: interpolating the location straight into the + // path produces `//demo.dotcms.comSYSTEM_HOST`, which resolves to nothing. + expect(request.assetPath).toBe(`//${SYSTEM_HOST.hostname}/`); + }); + + it('should not ask for folders in all site content', () => { + store.initContentDrive({ + currentSite: SYSTEM_HOST, + path: DEFAULT_PATH, + filters: {}, + isTreeExpanded: false + }); + + // Folders are not results in a flat listing that spans the whole site, and the + // tree is still there to navigate them. + expect(store.$request().showFolders).toBe(false); + }); + + it('should not ask for folders in System Host, which has none', () => { + store.initContentDrive({ + currentSite: SYSTEM_HOST, + path: 'SYSTEM_HOST', + filters: {}, + isTreeExpanded: false + }); + + expect(store.$request().showFolders).toBe(false); + }); + + it('should still ask for folders at the site root', () => { + store.initContentDrive({ + currentSite: SYSTEM_HOST, + path: '/', + filters: {}, + isTreeExpanded: false + }); + + // The top-level folders sit at the root, so they are part of what is there. + expect(store.$request().showFolders).toBe(true); + }); + }); + it('should include title filter in request when provided', () => { const filters = { title: 'Blog Post' @@ -561,14 +670,15 @@ describe('DotContentDriveStore', () => { it('should KEEP showFolders true when a languageId filter is provided', () => { // Folders have no language, so a locale filter — which picks a *version* of content - // — must not tear down the structure being navigated. + // — must not tear down the structure being navigated. At the site root for the same + // reason as the sibling test above. const filters = { languageId: ['en'] }; store.initContentDrive({ currentSite: SYSTEM_HOST, - path: DEFAULT_PATH, + path: ROOT_PATH, filters, isTreeExpanded: false }); @@ -613,7 +723,7 @@ describe('DotContentDriveStore', () => { it('should drop the status filter when filters are cleared', () => { store.initContentDrive({ currentSite: SYSTEM_HOST, - path: DEFAULT_PATH, + path: ROOT_PATH, filters: { status: ['ARCHIVED'] }, isTreeExpanded: false }); @@ -660,9 +770,11 @@ describe('DotContentDriveStore', () => { }); it('should set showFolders to true when no filters are provided', () => { + // At the site root: with no location at all this is now all site content, which + // asks for no folders whatever the filters say. store.initContentDrive({ currentSite: SYSTEM_HOST, - path: DEFAULT_PATH, + path: ROOT_PATH, filters: {}, isTreeExpanded: false }); diff --git a/core-web/libs/portlets/dot-content-drive/portlet/src/lib/store/dot-content-drive.store.ts b/core-web/libs/portlets/dot-content-drive/portlet/src/lib/store/dot-content-drive.store.ts index 33f500008349..b87de642ca3d 100644 --- a/core-web/libs/portlets/dot-content-drive/portlet/src/lib/store/dot-content-drive.store.ts +++ b/core-web/libs/portlets/dot-content-drive/portlet/src/lib/store/dot-content-drive.store.ts @@ -60,8 +60,10 @@ import { buildUserSearchablePayload, decodeFilters, getUserSearchableActive, + listsFolders, parseWorkflowFilter, sortedEncodedFilters, + toRequestLocation, withFilterDefaults } from '../utils/functions'; @@ -125,8 +127,14 @@ export const DotContentDriveStore = signalStore( userSearchableFields() ); + // One value says where the user is; this is where it becomes the two the + // endpoint expects. Mapped rather than interpolated: pasting the location + // into the path yields `//demo.dotcms.comSYSTEM_HOST` for a reserved word. + const location = toRequestLocation(currentSite()?.hostname, path()); + return { - assetPath: `//${currentSite()?.hostname}${path() || '/'}`, + assetPath: location.assetPath, + browseScope: location.browseScope, // Off only when explicitly turned off. The key is seeded on every path // that builds filters (see `withFilterDefaults`), so a missing one means // state that predates the seeding, not a deliberate opt-out. @@ -162,6 +170,9 @@ export const DotContentDriveStore = signalStore( // and pinning it would contradict an Archived selection. status: filters()?.status?.length ? filters()?.status : undefined, showFolders: + // Folders are not results in a listing that spans the whole site, + // and System Host has none. + listsFolders(location.browseScope) && page.hasMoreFolders && !filters()?.baseType?.length && !filters()?.contentType?.length && diff --git a/core-web/libs/portlets/dot-content-drive/portlet/src/lib/utils/functions.ts b/core-web/libs/portlets/dot-content-drive/portlet/src/lib/utils/functions.ts index dc8d376b12e1..bddd599318f7 100644 --- a/core-web/libs/portlets/dot-content-drive/portlet/src/lib/utils/functions.ts +++ b/core-web/libs/portlets/dot-content-drive/portlet/src/lib/utils/functions.ts @@ -8,6 +8,7 @@ import { PERMISSIONS_TYPE, DotContentDriveActionableFolder, DotContentDriveActionableItem, + DotContentDriveBrowseScope, DotFolder, DotSite, FolderSearchView, @@ -22,8 +23,10 @@ import { FOLDER_NAME_FILTER_MIN_LENGTH, FOLDER_TREE_HIERARCHY_PAGE_SIZE, FOLDER_TREE_PAGE_SIZE, + ROOT_PATH, SHARED_ASSETS_ENABLED_VALUE, SHARED_ASSETS_FILTER_KEY, + SYSTEM_HOST_PATH, USER_SEARCHABLE_PREFIX } from '../shared/constants'; import { @@ -890,3 +893,49 @@ export function canAddChildrenTo( return permissions.includes(PERMISSIONS_TYPE.CAN_ADD_CHILDREN); } + +/** + * Turns the one value that says where the user is browsing into the two the endpoint expects. + * + * The sidebar can select four things and the URL carries one value for all of them: absent means + * all site content, `/` means the site root, a deeper path means that folder, and a reserved word + * means System Host. Keeping it to one value is what stops a location and a scope disagreeing, + * and this is the single place the two representations meet. + * + * Written as a mapping rather than interpolation on purpose. Pasting the location into the path + * produces `//demo.dotcms.comSYSTEM_HOST` for the reserved word, which resolves to nothing. + * + * A folder deliberately gets **no** scope: it is addressed by its path, the endpoint refuses a + * scope alongside a folder path, and a scope there is what would turn the listing into every + * descendant. + */ +export function toRequestLocation( + hostname: string | undefined, + path: string | undefined +): { assetPath: string; browseScope?: DotContentDriveBrowseScope } { + const siteRoot = `//${hostname}/`; + + if (!path?.length) { + return { assetPath: siteRoot, browseScope: 'ALL' }; + } + + if (path === SYSTEM_HOST_PATH) { + return { assetPath: siteRoot, browseScope: 'SYSTEM_HOST' }; + } + + if (path === ROOT_PATH) { + return { assetPath: siteRoot, browseScope: 'ROOT' }; + } + + return { assetPath: `//${hostname}${path}` }; +} + +/** + * Whether the listing should ask for folders at all. + * + * Folders are not results in a listing that spans the whole site, and System Host has none, so + * both of those scopes ask for none. The tree is still there to navigate them. + */ +export function listsFolders(browseScope: DotContentDriveBrowseScope | undefined): boolean { + return browseScope !== 'ALL' && browseScope !== 'SYSTEM_HOST'; +} From a0a92993464ef6b67e31dbf564688c39770cd514 Mon Sep 17 00:00:00 2001 From: Jalinson Diaz Date: Mon, 14 Sep 2026 13:33:10 -0300 Subject: [PATCH 14/44] feat(dotcms-ui): the sidebar's first entry, and a column that scrolls in one place (#37426) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit All Site Content is a button rather than a tree node, deliberately: the hierarchy beside it owns arrow-key navigation, and a row that expands nothing must not carry a disclosure control that does nothing. Choosing it clears the location and the tree's selection together, in the store, because exactly one entry is ever selected and the tree cannot represent this one. No sticky or fixed positioning. The sidebar is a column in which only the hierarchy scrolls, so whatever sits below it stays put because nothing pushes it. The tree loads lazily and grows as folders open, so an entry that merely followed it in the flow would drift further out of reach with every expansion. The 1.25rem inset moves with the row. It is documented in this component as the amount that centres the first row on the toolbar's search box and tree toggler, and the first row is no longer the tree's. Two test lessons. Keeping "is this entry selected" as a component computed was untestable — a computed cannot track a store signal that a spec mocks as a plain function — so it moved to the store, where the one-at-a-time invariant already lives and where the System Host row will ask the same question. And the shell renders the sidebar, so its own store mock needed the new member; that surfaced as 150 failures in one file, all the same missing function. 1382 tests pass across the portlet. Typecheck clean for the files touched. Co-Authored-By: Claude Opus 5 --- .../dot-content-drive-sidebar.component.html | 49 ++++++++++++++---- ...ot-content-drive-sidebar.component.spec.ts | 51 ++++++++++++++++++- .../dot-content-drive-sidebar.component.ts | 26 +++++++--- .../dot-content-drive-shell.component.spec.ts | 3 ++ .../src/lib/store/dot-content-drive.store.ts | 9 ++++ .../lib/store/features/sidebar/withSidebar.ts | 22 +++++++- .../WEB-INF/messages/Language.properties | 4 +- .../data-model.md | 2 +- .../37426-content-drive-browse-scopes/spec.md | 2 +- 9 files changed, 145 insertions(+), 23 deletions(-) diff --git a/core-web/libs/portlets/dot-content-drive/portlet/src/lib/components/dot-content-drive-sidebar/dot-content-drive-sidebar.component.html b/core-web/libs/portlets/dot-content-drive/portlet/src/lib/components/dot-content-drive-sidebar/dot-content-drive-sidebar.component.html index f75390c816b2..81b1a9f19b3f 100644 --- a/core-web/libs/portlets/dot-content-drive/portlet/src/lib/components/dot-content-drive-sidebar/dot-content-drive-sidebar.component.html +++ b/core-web/libs/portlets/dot-content-drive/portlet/src/lib/components/dot-content-drive-sidebar/dot-content-drive-sidebar.component.html @@ -1,11 +1,38 @@ - + + + + + {{ 'content-drive.sidebar.site-hierarchy' | dm }} + + + +
+ +
diff --git a/core-web/libs/portlets/dot-content-drive/portlet/src/lib/components/dot-content-drive-sidebar/dot-content-drive-sidebar.component.spec.ts b/core-web/libs/portlets/dot-content-drive/portlet/src/lib/components/dot-content-drive-sidebar/dot-content-drive-sidebar.component.spec.ts index 3eb5de0d2554..fd4e2d7106ab 100644 --- a/core-web/libs/portlets/dot-content-drive/portlet/src/lib/components/dot-content-drive-sidebar/dot-content-drive-sidebar.component.spec.ts +++ b/core-web/libs/portlets/dot-content-drive/portlet/src/lib/components/dot-content-drive-sidebar/dot-content-drive-sidebar.component.spec.ts @@ -1,7 +1,13 @@ -import { createComponentFactory, mockProvider, Spectator } from '@openng/spectator/vitest'; +import { + byTestId, + createComponentFactory, + mockProvider, + Spectator +} from '@openng/spectator/vitest'; import { of } from 'rxjs'; import { Mock, Mocked, vi } from 'vitest'; +import { signal } from '@angular/core'; import { fakeAsync, tick } from '@angular/core/testing'; import { TreeNodeExpandEvent, TreeNodeSelectEvent } from 'primeng/tree'; @@ -99,6 +105,10 @@ describe('DotContentDriveSidebarComponent', () => { } ]; + // A real signal, not a vi.fn: the row's selected state is read in an OnPush template, so it + // only re-renders when a signal it reads is invalidated. Same pattern the toolbar spec uses. + const allSiteContentSelected = signal(false); + const createComponent = createComponentFactory({ component: DotContentDriveSidebarComponent, imports: [DotTreeFolderComponent], @@ -128,7 +138,9 @@ describe('DotContentDriveSidebarComponent', () => { loadChildFolders: vi.fn(), patchContextMenu: vi.fn(), updateFolders: vi.fn(), - setSelectedNode: vi.fn() + setSelectedNode: vi.fn(), + selectAllSiteContent: vi.fn(), + $allSiteContentSelected: allSiteContentSelected }), mockProvider(DotMessageService, { get: vi.fn().mockImplementation((key: string) => key) @@ -137,6 +149,8 @@ describe('DotContentDriveSidebarComponent', () => { }); beforeEach(() => { + allSiteContentSelected.set(false); + spectator = createComponent({ providers: [ mockProvider(DotFolderService, { @@ -150,6 +164,39 @@ describe('DotContentDriveSidebarComponent', () => { spectator.detectChanges(); }); + describe('all site content', () => { + const row = () => spectator.query(byTestId('all-site-content')); + + it('should offer a row above the hierarchy', () => { + expect(row()).toBeTruthy(); + }); + + it('should ask the store for all site content when the row is chosen', () => { + spectator.click(byTestId('all-site-content')); + + expect(contentDriveStore.selectAllSiteContent).toHaveBeenCalled(); + }); + + it('should be reachable by keyboard, not only by pointer', () => { + // The hierarchy beside it is a tree with its own arrow-key handling, so this row has to + // carry its own semantics rather than inheriting the tree's. + expect(row()?.tagName.toLowerCase()).toBe('button'); + }); + + it('should not read as selected while a folder is being browsed', () => { + // The default mock browses '/test/path'. + expect(row()?.getAttribute('aria-selected')).toBe('false'); + }); + + it('should read as selected when the drive carries no location', () => { + allSiteContentSelected.set(true); + + spectator.detectChanges(); + + expect(row()?.getAttribute('aria-selected')).toBe('true'); + }); + }); + describe('HTML Rendering', () => { it('should render dot-tree-folder component', () => { const treeComponent = spectator.query(DotTreeFolderComponent); diff --git a/core-web/libs/portlets/dot-content-drive/portlet/src/lib/components/dot-content-drive-sidebar/dot-content-drive-sidebar.component.ts b/core-web/libs/portlets/dot-content-drive/portlet/src/lib/components/dot-content-drive-sidebar/dot-content-drive-sidebar.component.ts index 14bbc6f27552..301c97bc4d39 100644 --- a/core-web/libs/portlets/dot-content-drive/portlet/src/lib/components/dot-content-drive-sidebar/dot-content-drive-sidebar.component.ts +++ b/core-web/libs/portlets/dot-content-drive/portlet/src/lib/components/dot-content-drive-sidebar/dot-content-drive-sidebar.component.ts @@ -24,6 +24,7 @@ import { DotTreeFolderComponent, LOAD_MORE_NODE_TYPE } from '@dotcms/portlets/content-drive/ui'; +import { DotMessagePipe } from '@dotcms/ui'; import { DotContentDriveStore } from '../../store/dot-content-drive.store'; import { appendLoadMoreNodes, mergeFolderNodePage } from '../../utils/functions'; @@ -37,14 +38,16 @@ import { appendLoadMoreNodes, mergeFolderNodePage } from '../../utils/functions' selector: 'dot-content-drive-sidebar', templateUrl: './dot-content-drive-sidebar.component.html', changeDetection: ChangeDetectionStrategy.OnPush, - imports: [DotTreeFolderComponent], - host: { class: 'block w-full h-full' }, + imports: [DotTreeFolderComponent, DotMessagePipe], + host: { class: 'flex h-full w-full flex-col' }, styles: ` - /* The top inset used to come from the site-name header that sat above the tree. With the - site now named by the tree's own root row, the tree owns that spacing — and the amount is - what centers that first row on the toolbar's search box and tree toggler beside it. */ + /* The top inset used to come from the site-name header that sat above the tree, then from + the tree itself once the site was named by its own root row. It now belongs to whatever + is first in the column, which is the all-site-content row — the amount is what centers + that first row on the toolbar's search box and tree toggler beside it, so it has to + travel with the row rather than stay on the tree. */ :host ::ng-deep .p-tree { - padding: 1.25rem 0.75rem 0.75rem; + padding: 0 0.75rem 0.75rem; } ` }) @@ -53,6 +56,9 @@ export class DotContentDriveSidebarComponent { readonly #injector = inject(Injector); readonly $loading = this.#store.sidebarLoading; + + /** Whether the sidebar's first entry, all site content, is the selected one. */ + readonly $allSiteContentSelected = this.#store.$allSiteContentSelected; readonly $folders = this.#store.folders; readonly $selectedNode = this.#store.selectedNode; readonly $currentSite = this.#store.currentSite; @@ -151,6 +157,14 @@ export class DotContentDriveSidebarComponent { { injector: this.#injector } ); } + /** + * Chooses the whole site. The store clears the tree's selection as it does so, because exactly + * one entry in the sidebar is ever selected and the tree cannot represent this one. + */ + protected onSelectAllSiteContent(): void { + this.#store.selectAllSiteContent(); + } + /** * Handles node selection events * diff --git a/core-web/libs/portlets/dot-content-drive/portlet/src/lib/dot-content-drive-shell/dot-content-drive-shell.component.spec.ts b/core-web/libs/portlets/dot-content-drive/portlet/src/lib/dot-content-drive-shell/dot-content-drive-shell.component.spec.ts index 625c1828cd1d..7f3c68286541 100644 --- a/core-web/libs/portlets/dot-content-drive/portlet/src/lib/dot-content-drive-shell/dot-content-drive-shell.component.spec.ts +++ b/core-web/libs/portlets/dot-content-drive/portlet/src/lib/dot-content-drive-shell/dot-content-drive-shell.component.spec.ts @@ -275,6 +275,9 @@ describe('DotContentDriveShellComponent', () => { folders: vi.fn(), selectedNode: vi.fn(), setSelectedNode: vi.fn(), + // The shell renders the sidebar, which asks the store which entry is selected. + $allSiteContentSelected: vi.fn().mockReturnValue(false), + selectAllSiteContent: vi.fn(), sidebarLoading: vi.fn(), closeDialog: vi.fn(), patchContextMenu: vi.fn(), diff --git a/core-web/libs/portlets/dot-content-drive/portlet/src/lib/store/dot-content-drive.store.ts b/core-web/libs/portlets/dot-content-drive/portlet/src/lib/store/dot-content-drive.store.ts index b87de642ca3d..1e65c169b386 100644 --- a/core-web/libs/portlets/dot-content-drive/portlet/src/lib/store/dot-content-drive.store.ts +++ b/core-web/libs/portlets/dot-content-drive/portlet/src/lib/store/dot-content-drive.store.ts @@ -661,6 +661,15 @@ export const DotContentDriveStore = signalStore( withActionExecution(), withPushPublishEnvironments(), withSitePermissions(), + withComputed(({ path }) => ({ + /** + * Whether the sidebar's first entry, all site content, is the selected one. + * + * Derived from the location rather than stored beside it: an absent location *is* what all + * site content means, so a second piece of state saying so could only ever disagree. + */ + $allSiteContentSelected: computed(() => !path()) + })), withComputed(({ selectedNode, siteCanAddChildren }) => ({ /** * Whether the browsed folder accepts new children. diff --git a/core-web/libs/portlets/dot-content-drive/portlet/src/lib/store/features/sidebar/withSidebar.ts b/core-web/libs/portlets/dot-content-drive/portlet/src/lib/store/features/sidebar/withSidebar.ts index c1ab51cd0169..34e34b0302e3 100644 --- a/core-web/libs/portlets/dot-content-drive/portlet/src/lib/store/features/sidebar/withSidebar.ts +++ b/core-web/libs/portlets/dot-content-drive/portlet/src/lib/store/features/sidebar/withSidebar.ts @@ -16,7 +16,7 @@ import { catchError } from 'rxjs/operators'; import { DotFolderService } from '@dotcms/data-access'; import { DotFolderTreeNodeItem } from '@dotcms/portlets/content-drive/ui'; -import { SYSTEM_HOST } from '../../../shared/constants'; +import { DEFAULT_PAGE, DEFAULT_PATH, SYSTEM_HOST } from '../../../shared/constants'; import { DotContentDriveState } from '../../../shared/models'; import { applyLoadMoreToHierarchy, @@ -158,6 +158,26 @@ export function withSidebar() { }); }, + /** + * Selects all site content: the whole current site at any depth, which is the one + * sidebar entry that names no place inside the hierarchy. + * + * Clearing the selected node is half the job. Exactly one thing in the sidebar is ever + * selected, and the tree cannot represent this entry, so leaving a node selected would + * have the sidebar claiming the user is in two places at once. + * + * The location is cleared rather than set to the root: absent is what all site content + * looks like in the URL, which is also what links made before this feature carry. + */ + selectAllSiteContent: () => { + patchState(store, { + path: DEFAULT_PATH, + selectedNode: undefined, + pagination: { ...store.pagination(), page: 1, offset: 0 }, + pages: [DEFAULT_PAGE] + }); + }, + /** * Selects the tree's root row, the one that stands for the site rather than a folder. * diff --git a/dotCMS/src/main/webapp/WEB-INF/messages/Language.properties b/dotCMS/src/main/webapp/WEB-INF/messages/Language.properties index b816dfa8ee1c..3218c3f2ea13 100644 --- a/dotCMS/src/main/webapp/WEB-INF/messages/Language.properties +++ b/dotCMS/src/main/webapp/WEB-INF/messages/Language.properties @@ -7383,7 +7383,9 @@ content-drive.toast.download-success-detail=The download has started. content-drive.chip-filter.overflow-label={0} and {1} more content-drive.filters.clear-all=Clear all -content-drive.shared-assets-filter.title=Show Shared Assets +content-drive.shared-assets-filter.title=Show System Host +content-drive.sidebar.all-site-content=All Site Content +content-drive.sidebar.site-hierarchy=Site Hierarchy ## Content Drive - Keyboard shortcuts (labels are the source for the author-facing docs) content-drive.shortcut.search=Focus the search field diff --git a/specs/37426-content-drive-browse-scopes/data-model.md b/specs/37426-content-drive-browse-scopes/data-model.md index 0dba0857904a..a76dfc1c2865 100644 --- a/specs/37426-content-drive-browse-scopes/data-model.md +++ b/specs/37426-content-drive-browse-scopes/data-model.md @@ -58,7 +58,7 @@ Two rules protect this table. Reserved words can never collide with a folder, be ## Sidebar selection (frontend) -Exactly one of four things is selected: the All Site Content row, the site row, a folder node, or the System Host row. The tree's existing `selectedNode` continues to represent the middle two; the two new rows live outside the tree and must clear it when chosen, and be cleared by it. The System Host row is fixed at the bottom of the sidebar and the hierarchy scrolls beneath it, so a growing tree can never push it out of reach. +Exactly one of four things is selected: the All Site Content row, the site row, a folder node, or the System Host row. The tree's existing `selectedNode` continues to represent the middle two; the two new rows live outside the tree and must clear it when chosen, and be cleared by it. The System Host row sits below the hierarchy, which scrolls within its own area, so a growing tree can never push it out of reach. | Selection | Drop target | Add content | |---|---|---| diff --git a/specs/37426-content-drive-browse-scopes/spec.md b/specs/37426-content-drive-browse-scopes/spec.md index 708071247ff6..bb73271534a6 100644 --- a/specs/37426-content-drive-browse-scopes/spec.md +++ b/specs/37426-content-drive-browse-scopes/spec.md @@ -89,7 +89,7 @@ Having selected some content, a user drags it onto the **System Host** entry to #### Sidebar structure - **FR-001**: The sidebar MUST offer three kinds of selection: an **All Site Content** entry at the top, the **site hierarchy** (the site row and its folders), and a **System Host** entry at the bottom. -- **FR-001a**: The **System Host** entry MUST stay fixed at the bottom of the sidebar while the hierarchy scrolls beneath it. The hierarchy loads lazily and grows as folders are expanded, so its bottom edge moves; an entry positioned after it would become progressively harder to reach. The top entry needs no such treatment, since scrolling back to the top is a known, cheap gesture. +- **FR-001a**: The hierarchy MUST scroll within its own area rather than growing the sidebar, so that the **System Host** entry below it stays visible without hunting for it. The hierarchy loads lazily and grows as folders are expanded, so an entry that merely followed it in the page flow would drift further out of reach with every expansion. The entry above the hierarchy needs nothing special: scrolling back to the top is a cheap, known gesture. - **FR-002**: **All Site Content** and **System Host** MUST be presented as plain sidebar sections, not as nodes of the site hierarchy: no expansion control, no children, and nothing beneath them to navigate into. This is about structure only. It does not stop System Host accepting content, which the rules below require of it. - **FR-003**: Exactly one entry MUST be selected at any time; selecting one clears the previous selection. - **FR-004**: The sidebar MUST NOT show item counts next to any entry. From 1eac9b39f30dea182309a1d406a97587025c76bb Mon Sep 17 00:00:00 2001 From: Jalinson Diaz Date: Mon, 14 Sep 2026 13:41:38 -0300 Subject: [PATCH 15/44] feat(dotcms-ui): all site content names no place to put things (#37426) Creating, uploading and dropping are refused there, and the refusal is not a permission answer: the view spans every folder in the site, so there is no single place for new content to land. The scope therefore wins over a folder that does grant CAN_ADD_CHILDREN, because the question is not whether the user may add content, it is where it would go. Two refusals now reach the same disabled buttons and they must not say the same thing. Telling someone they lack permission here would send them to an administrator for a problem they do not have, so the tooltip names the scope and points at the two places that do accept content. The toolbar spec mirrors the store's gate so its tests drive the real signals rather than a hardcoded answer; the mirror gained the same rule, which is why the two gate tests passed on the first run and only the tooltip was red. 1382 tests pass. Co-Authored-By: Claude Opus 5 --- ...ot-content-drive-toolbar.component.spec.ts | 44 ++++++++++++++++++- .../dot-content-drive-toolbar.component.ts | 20 +++++++-- .../src/lib/store/dot-content-drive.store.ts | 9 +++- .../WEB-INF/messages/Language.properties | 1 + 4 files changed, 69 insertions(+), 5 deletions(-) diff --git a/core-web/libs/portlets/dot-content-drive/portlet/src/lib/components/dot-content-drive-toolbar/dot-content-drive-toolbar.component.spec.ts b/core-web/libs/portlets/dot-content-drive/portlet/src/lib/components/dot-content-drive-toolbar/dot-content-drive-toolbar.component.spec.ts index 40426d5e9bdd..4651359f1cde 100644 --- a/core-web/libs/portlets/dot-content-drive/portlet/src/lib/components/dot-content-drive-toolbar/dot-content-drive-toolbar.component.spec.ts +++ b/core-web/libs/portlets/dot-content-drive/portlet/src/lib/components/dot-content-drive-toolbar/dot-content-drive-toolbar.component.spec.ts @@ -67,6 +67,7 @@ describe('DotContentDriveToolbarComponent', () => { // Real signals so the component's computeds re-run when they change const isTreeExpandedSignal = signal(false); + const allSiteContentSelectedSignal = signal(false); const filtersSignal = signal({}); const selectedItemsSignal = signal([]); const selectedNodeSignal = signal< @@ -135,9 +136,14 @@ describe('DotContentDriveToolbarComponent', () => { clearUserSearchableFilters: vi.fn(), actionExecution: actionExecutionSignal, siteCanAddChildren: siteCanAddChildrenSignal, + $allSiteContentSelected: allSiteContentSelectedSignal, // Mirrors the store's own computed so the toolbar tests still drive the gate - // through the two signals it derives from, not through a hardcoded answer. + // through the signals it derives from, not through a hardcoded answer. $canAddChildren: computed(() => { + if (allSiteContentSelectedSignal()) { + return false; + } + const permissions = selectedNodeSignal()?.data?.permissions; if (!permissions?.length) { @@ -830,6 +836,42 @@ describe('DotContentDriveToolbarComponent', () => { expect(spectator.component.$canAddChildren()).toBe(true); }); + // All site content spans every folder in the site, so there is no single place for new + // content to land. The affordances are refused for a different reason than a permission + // denial, and have to say so. + describe('in all site content', () => { + afterEach(() => allSiteContentSelectedSignal.set(false)); + + it('should refuse creation even where the site accepts children', async () => { + siteCanAddChildrenSignal.set(true); + allSiteContentSelectedSignal.set(true); + await settleToolbarAnimation(spectator); + + expect(spectator.component.$canAddChildren()).toBe(false); + }); + + it('should refuse creation even where the folder permits it', async () => { + allSiteContentSelectedSignal.set(true); + await withPermissions(['READ', 'CAN_ADD_CHILDREN']); + + // The scope wins over the permission: the question is not whether the user may + // add content, it is where it would go. + expect(spectator.component.$canAddChildren()).toBe(false); + }); + + it('should explain the scope rather than blaming permissions', async () => { + allSiteContentSelectedSignal.set(true); + await withPermissions(['READ', 'CAN_ADD_CHILDREN']); + + expect(spectator.component.$addChildrenTooltip()).not.toBe( + 'content-drive.add-new.no-add-children' + ); + expect(spectator.component.$addChildrenTooltip()).toBe( + 'content-drive.add-new.no-place-to-add' + ); + }); + }); + // The site root: the parent is the host, not a folder, so the tree's site node carries no // permissions and the answer comes from the store's own lookup on the site instead. describe('at the site root', () => { diff --git a/core-web/libs/portlets/dot-content-drive/portlet/src/lib/components/dot-content-drive-toolbar/dot-content-drive-toolbar.component.ts b/core-web/libs/portlets/dot-content-drive/portlet/src/lib/components/dot-content-drive-toolbar/dot-content-drive-toolbar.component.ts index 3adf20fd83d1..99d2fff84fe5 100644 --- a/core-web/libs/portlets/dot-content-drive/portlet/src/lib/components/dot-content-drive-toolbar/dot-content-drive-toolbar.component.ts +++ b/core-web/libs/portlets/dot-content-drive/portlet/src/lib/components/dot-content-drive-toolbar/dot-content-drive-toolbar.component.ts @@ -227,9 +227,23 @@ export class DotContentDriveToolbarComponent { protected readonly $canAddChildren = this.#store.$canAddChildren; /** Empty when creation is allowed, so the buttons carry no tooltip in the normal case. */ - protected readonly $addChildrenTooltip = computed(() => - this.$canAddChildren() ? '' : 'content-drive.add-new.no-add-children' - ); + /** + * Why creating and uploading are unavailable, when they are. + * + * Two different refusals reach the same disabled buttons, and they must not say the same + * thing. In all site content nothing is wrong with the user's permissions: the view simply + * spans the whole site and names no place for new content to land. Telling them they lack + * permission there would send them to an administrator for a problem they do not have. + */ + protected readonly $addChildrenTooltip = computed(() => { + if (this.$canAddChildren()) { + return ''; + } + + return this.#store.$allSiteContentSelected() + ? 'content-drive.add-new.no-place-to-add' + : 'content-drive.add-new.no-add-children'; + }); protected readonly $uploadBaseType = computed(() => { const data = this.#store.selectedNode()?.data; diff --git a/core-web/libs/portlets/dot-content-drive/portlet/src/lib/store/dot-content-drive.store.ts b/core-web/libs/portlets/dot-content-drive/portlet/src/lib/store/dot-content-drive.store.ts index 1e65c169b386..77b4e1b933e2 100644 --- a/core-web/libs/portlets/dot-content-drive/portlet/src/lib/store/dot-content-drive.store.ts +++ b/core-web/libs/portlets/dot-content-drive/portlet/src/lib/store/dot-content-drive.store.ts @@ -670,7 +670,7 @@ export const DotContentDriveStore = signalStore( */ $allSiteContentSelected: computed(() => !path()) })), - withComputed(({ selectedNode, siteCanAddChildren }) => ({ + withComputed(({ selectedNode, siteCanAddChildren, $allSiteContentSelected }) => ({ /** * Whether the browsed folder accepts new children. * @@ -690,6 +690,13 @@ export const DotContentDriveStore = signalStore( * the affordances off and on for the common case, and the server refuses the write anyway. */ $canAddChildren: computed(() => { + // All site content spans every folder in the site, so there is no single place for + // new content to land. This is not a permission answer and it is not negotiable by + // one: the question is not whether the user may add content, it is where it would go. + if ($allSiteContentSelected()) { + return false; + } + const permissions = (selectedNode()?.data as { permissions?: string[] } | undefined) ?.permissions; diff --git a/dotCMS/src/main/webapp/WEB-INF/messages/Language.properties b/dotCMS/src/main/webapp/WEB-INF/messages/Language.properties index 3218c3f2ea13..dc413b75227e 100644 --- a/dotCMS/src/main/webapp/WEB-INF/messages/Language.properties +++ b/dotCMS/src/main/webapp/WEB-INF/messages/Language.properties @@ -7385,6 +7385,7 @@ content-drive.chip-filter.overflow-label={0} and {1} more content-drive.filters.clear-all=Clear all content-drive.shared-assets-filter.title=Show System Host content-drive.sidebar.all-site-content=All Site Content +content-drive.add-new.no-place-to-add=All Site Content spans the whole site. Choose the site root or a folder to add content. content-drive.sidebar.site-hierarchy=Site Hierarchy ## Content Drive - Keyboard shortcuts (labels are the source for the author-facing docs) From 070be7cedaf3f4c488c7e8d05d9e1a82de6a966e Mon Sep 17 00:00:00 2001 From: Jalinson Diaz Date: Mon, 14 Sep 2026 14:19:45 -0300 Subject: [PATCH 16/44] feat(dotcms-ui): take the System Host chip away where it decides nothing (#37426) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Removed rather than disabled. System Host content can only ever sit at the System Host root, so it cannot turn up inside a site folder, and the site-root and System Host scopes each already answer the question this chip asks. A control with nothing to decide should not be sitting there. The reflow that argued for disabling it is handled by the animation instead: it uses the same enter and leave the field-filter chips beside it use, which collapse max-width rather than fading, so the rest of the row slides instead of jumping under the pointer. That also keeps this out of libs/ui — the shared chip has no disabled input, so disabling it would have meant adding a capability to a component the Asset Picker renders, to serve a rule that is ours alone. Two chip-row tests asserted six chips in canonical order and six in the tab order. Rather than lowering them to five, they now run in all site content, which is where the full row exists: what they guard is that every chip is present and correctly ordered when it applies, not how many happen to apply in the default state. 1382 tests pass. Co-Authored-By: Claude Opus 5 --- .../dot-content-drive-toolbar.component.html | 16 ++++++++-- ...ot-content-drive-toolbar.component.spec.ts | 32 +++++++++++++++++++ .../dot-content-drive-toolbar.component.ts | 3 ++ .../37426-content-drive-browse-scopes/spec.md | 4 +-- 4 files changed, 51 insertions(+), 4 deletions(-) diff --git a/core-web/libs/portlets/dot-content-drive/portlet/src/lib/components/dot-content-drive-toolbar/dot-content-drive-toolbar.component.html b/core-web/libs/portlets/dot-content-drive/portlet/src/lib/components/dot-content-drive-toolbar/dot-content-drive-toolbar.component.html index d0f79a46385d..7241a5562cea 100644 --- a/core-web/libs/portlets/dot-content-drive/portlet/src/lib/components/dot-content-drive-toolbar/dot-content-drive-toolbar.component.html +++ b/core-web/libs/portlets/dot-content-drive/portlet/src/lib/components/dot-content-drive-toolbar/dot-content-drive-toolbar.component.html @@ -79,8 +79,20 @@ and the field filters — sit in the same row as the shared ones. --> - + precondition for the filters that narrow within them. + + Only while all site content is selected. System Host content can only ever sit + at the System Host root, so it can never turn up inside a site folder — and + the site-root and System Host scopes each answer the question this chip asks, + leaving it nothing to decide. It animates the way the field-filter chips + beside it do, collapsing its width rather than vanishing, so the rest of the + row slides instead of jumping under the pointer. --> + @if ($allSiteContentSelected()) { + + } - - {{ 'content-drive.sidebar.site-hierarchy' | dm }} - - -
+
+ + + diff --git a/core-web/libs/portlets/dot-content-drive/portlet/src/lib/components/dot-content-drive-sidebar/dot-content-drive-sidebar.component.spec.ts b/core-web/libs/portlets/dot-content-drive/portlet/src/lib/components/dot-content-drive-sidebar/dot-content-drive-sidebar.component.spec.ts index fd4e2d7106ab..f223a36dd0d2 100644 --- a/core-web/libs/portlets/dot-content-drive/portlet/src/lib/components/dot-content-drive-sidebar/dot-content-drive-sidebar.component.spec.ts +++ b/core-web/libs/portlets/dot-content-drive/portlet/src/lib/components/dot-content-drive-sidebar/dot-content-drive-sidebar.component.spec.ts @@ -108,6 +108,7 @@ describe('DotContentDriveSidebarComponent', () => { // A real signal, not a vi.fn: the row's selected state is read in an OnPush template, so it // only re-renders when a signal it reads is invalidated. Same pattern the toolbar spec uses. const allSiteContentSelected = signal(false); + const systemHostSelected = signal(false); const createComponent = createComponentFactory({ component: DotContentDriveSidebarComponent, @@ -140,7 +141,9 @@ describe('DotContentDriveSidebarComponent', () => { updateFolders: vi.fn(), setSelectedNode: vi.fn(), selectAllSiteContent: vi.fn(), - $allSiteContentSelected: allSiteContentSelected + selectSystemHost: vi.fn(), + $allSiteContentSelected: allSiteContentSelected, + $systemHostSelected: systemHostSelected }), mockProvider(DotMessageService, { get: vi.fn().mockImplementation((key: string) => key) @@ -150,6 +153,7 @@ describe('DotContentDriveSidebarComponent', () => { beforeEach(() => { allSiteContentSelected.set(false); + systemHostSelected.set(false); spectator = createComponent({ providers: [ @@ -183,17 +187,59 @@ describe('DotContentDriveSidebarComponent', () => { expect(row()?.tagName.toLowerCase()).toBe('button'); }); - it('should not read as selected while a folder is being browsed', () => { + it('should not read as current while a folder is being browsed', () => { // The default mock browses '/test/path'. - expect(row()?.getAttribute('aria-selected')).toBe('false'); + expect(row()?.getAttribute('aria-current')).toBeNull(); }); - it('should read as selected when the drive carries no location', () => { + it('should read as current when the drive carries no location', () => { + // `aria-current`, not `aria-selected`: the latter is only meaningful on roles like + // option, tab or treeitem, and on a button it is dropped from the accessibility tree + // outright — which is how this was caught, as a row that announced nothing and looked + // identical whether or not it was the view you were on. allSiteContentSelected.set(true); spectator.detectChanges(); - expect(row()?.getAttribute('aria-selected')).toBe('true'); + expect(row()?.getAttribute('aria-current')).toBe('true'); + }); + }); + + describe('System Host', () => { + const row = () => spectator.query(byTestId('system-host')); + + it('should offer a row below the hierarchy', () => { + expect(row()).toBeTruthy(); + }); + + it('should sit after the hierarchy in document order', () => { + // Below the tree, not above it: System Host belongs to no site, so it reads as the + // other place you can be rather than as part of this site's structure. + const hierarchy = spectator.query(byTestId('hierarchy-scroll')); + + expect( + hierarchy?.compareDocumentPosition(row() as Node) & + Node.DOCUMENT_POSITION_FOLLOWING + ).toBeTruthy(); + }); + + it('should ask the store for System Host when the row is chosen', () => { + spectator.click(byTestId('system-host')); + + expect(contentDriveStore.selectSystemHost).toHaveBeenCalled(); + }); + + it('should be reachable by keyboard, not only by pointer', () => { + expect(row()?.tagName.toLowerCase()).toBe('button'); + }); + + it('should read as current only while System Host is what is being shown', () => { + expect(row()?.getAttribute('aria-current')).toBeNull(); + + systemHostSelected.set(true); + spectator.detectChanges(); + + expect(row()?.getAttribute('aria-current')).toBe('true'); }); }); diff --git a/core-web/libs/portlets/dot-content-drive/portlet/src/lib/components/dot-content-drive-sidebar/dot-content-drive-sidebar.component.ts b/core-web/libs/portlets/dot-content-drive/portlet/src/lib/components/dot-content-drive-sidebar/dot-content-drive-sidebar.component.ts index 301c97bc4d39..ad71fb08e672 100644 --- a/core-web/libs/portlets/dot-content-drive/portlet/src/lib/components/dot-content-drive-sidebar/dot-content-drive-sidebar.component.ts +++ b/core-web/libs/portlets/dot-content-drive/portlet/src/lib/components/dot-content-drive-sidebar/dot-content-drive-sidebar.component.ts @@ -59,6 +59,9 @@ export class DotContentDriveSidebarComponent { /** Whether the sidebar's first entry, all site content, is the selected one. */ readonly $allSiteContentSelected = this.#store.$allSiteContentSelected; + + /** Whether the sidebar's last entry, System Host, is the selected one. */ + readonly $systemHostSelected = this.#store.$systemHostSelected; readonly $folders = this.#store.folders; readonly $selectedNode = this.#store.selectedNode; readonly $currentSite = this.#store.currentSite; @@ -165,6 +168,13 @@ export class DotContentDriveSidebarComponent { this.#store.selectAllSiteContent(); } + /** + * Chooses System Host, which belongs to no site and so clears the tree's selection too. + */ + protected onSelectSystemHost(): void { + this.#store.selectSystemHost(); + } + /** * Handles node selection events * diff --git a/core-web/libs/portlets/dot-content-drive/portlet/src/lib/components/dot-content-drive-toolbar/components/dot-content-drive-search-input/dot-content-drive-search-input.component.spec.ts b/core-web/libs/portlets/dot-content-drive/portlet/src/lib/components/dot-content-drive-toolbar/components/dot-content-drive-search-input/dot-content-drive-search-input.component.spec.ts index c4cd5134b188..30511701083e 100644 --- a/core-web/libs/portlets/dot-content-drive/portlet/src/lib/components/dot-content-drive-toolbar/components/dot-content-drive-search-input/dot-content-drive-search-input.component.spec.ts +++ b/core-web/libs/portlets/dot-content-drive/portlet/src/lib/components/dot-content-drive-toolbar/components/dot-content-drive-search-input/dot-content-drive-search-input.component.spec.ts @@ -28,7 +28,8 @@ describe('DotContentDriveSearchInputComponent', () => { mockProvider(DotContentDriveStore, { getFilterValue: vi.fn().mockReturnValue(undefined), setGlobalSearch: vi.fn(), - selectRootNode: vi.fn() + selectRootNode: vi.fn(), + selectAllSiteContent: vi.fn() }), { provide: DotMessageService, @@ -75,7 +76,10 @@ describe('DotContentDriveSearchInputComponent', () => { spectator.triggerEventHandler(searchInput(), 'search', 'blog'); expect(store.setGlobalSearch).toHaveBeenCalledWith('blog'); - expect(store.selectRootNode).toHaveBeenCalled(); + // All site content, not the site row: the results span the whole site at any depth, and + // the site row now means the root alone. Selecting it would have the sidebar naming a + // narrower place than the list is showing. + expect(store.selectAllSiteContent).toHaveBeenCalled(); }); it('should clear the search in the store when an empty term is emitted', () => { @@ -85,7 +89,7 @@ describe('DotContentDriveSearchInputComponent', () => { spectator.triggerEventHandler(searchInput(), 'search', ''); expect(store.setGlobalSearch).toHaveBeenCalledWith(''); - expect(store.selectRootNode).toHaveBeenCalled(); + expect(store.selectAllSiteContent).toHaveBeenCalled(); }); // The claim lives here rather than in the shell because this component is the one holding the diff --git a/core-web/libs/portlets/dot-content-drive/portlet/src/lib/components/dot-content-drive-toolbar/components/dot-content-drive-search-input/dot-content-drive-search-input.component.ts b/core-web/libs/portlets/dot-content-drive/portlet/src/lib/components/dot-content-drive-toolbar/components/dot-content-drive-search-input/dot-content-drive-search-input.component.ts index 2aff99ad324c..b77fdc1f0c02 100644 --- a/core-web/libs/portlets/dot-content-drive/portlet/src/lib/components/dot-content-drive-toolbar/components/dot-content-drive-search-input/dot-content-drive-search-input.component.ts +++ b/core-web/libs/portlets/dot-content-drive/portlet/src/lib/components/dot-content-drive-toolbar/components/dot-content-drive-search-input/dot-content-drive-search-input.component.ts @@ -109,11 +109,13 @@ export class DotContentDriveSearchInputComponent implements OnDestroy { * A new search resets the folder scope: results are drive-wide, so leaving the tree pinned to * the previously selected folder would contradict what the list shows. * - * `selectRootNode()` rather than pinning a synthetic node: the tree's root is the real site - * row now (see `createSiteNode`), so there is no "All folders" node left to select. + * All site content is exactly that scope, and selecting it is now the honest way to say so. + * This used to select the tree's site row, with a comment regretting that there was no "All + * folders" node left to choose; there is one again, and the site row has since come to mean + * the root alone, which is narrower than what a search returns. */ protected onSearch(term: string): void { this.#store.setGlobalSearch(term); - this.#store.selectRootNode(); + this.#store.selectAllSiteContent(); } } diff --git a/core-web/libs/portlets/dot-content-drive/portlet/src/lib/dot-content-drive-shell/dot-content-drive-shell.component.spec.ts b/core-web/libs/portlets/dot-content-drive/portlet/src/lib/dot-content-drive-shell/dot-content-drive-shell.component.spec.ts index 7f3c68286541..8875192053ef 100644 --- a/core-web/libs/portlets/dot-content-drive/portlet/src/lib/dot-content-drive-shell/dot-content-drive-shell.component.spec.ts +++ b/core-web/libs/portlets/dot-content-drive/portlet/src/lib/dot-content-drive-shell/dot-content-drive-shell.component.spec.ts @@ -277,7 +277,9 @@ describe('DotContentDriveShellComponent', () => { setSelectedNode: vi.fn(), // The shell renders the sidebar, which asks the store which entry is selected. $allSiteContentSelected: vi.fn().mockReturnValue(false), + $systemHostSelected: vi.fn().mockReturnValue(false), selectAllSiteContent: vi.fn(), + selectSystemHost: vi.fn(), sidebarLoading: vi.fn(), closeDialog: vi.fn(), patchContextMenu: vi.fn(), @@ -2844,6 +2846,33 @@ describe('DotContentDriveShellComponent', () => { expect(store.setPath).toHaveBeenCalledWith('/documents/'); }); + it('should read the site row as the site root, not as no location at all', () => { + // The tree tells its site row apart from a folder by carrying an empty path. As a + // *location* that means the site root, which is a different thing from all site + // content — and all site content is what an absent location means. Without this + // translation the two collapse into each other and choosing the site row silently + // lands on the flat whole-site view. + const siteRow: DotFolderTreeNodeItem = { + key: 'site', + label: 'demo.dotcms.com', + data: { + id: 'site-123', + hostname: 'demo.dotcms.com', + path: '', + type: 'site' + }, + leaf: false + }; + + store.selectedNode.mockReturnValue(siteRow); + store.setPath.mockClear(); + + spectator.detectChanges(); + spectator.detectChanges(); + + expect(store.setPath).toHaveBeenCalledWith('/'); + }); + it('should not set path when selectedNode is null', () => { store.selectedNode.mockReturnValue(null); store.setPath.mockClear(); diff --git a/core-web/libs/portlets/dot-content-drive/portlet/src/lib/dot-content-drive-shell/dot-content-drive-shell.component.ts b/core-web/libs/portlets/dot-content-drive/portlet/src/lib/dot-content-drive-shell/dot-content-drive-shell.component.ts index 5d200af77a01..bf687be05d47 100644 --- a/core-web/libs/portlets/dot-content-drive/portlet/src/lib/dot-content-drive-shell/dot-content-drive-shell.component.ts +++ b/core-web/libs/portlets/dot-content-drive/portlet/src/lib/dot-content-drive-shell/dot-content-drive-shell.component.ts @@ -82,7 +82,8 @@ import { WARNING_MESSAGE_LIFE, ERROR_MESSAGE_LIFE, MOVE_TO_FOLDER_WORKFLOW_ACTION_ID, - NEW_CONTENT_MARKER + NEW_CONTENT_MARKER, + ROOT_PATH } from '../shared/constants'; import { DotContentDriveContentTypeSelectorPayload, @@ -721,8 +722,14 @@ export class DotContentDriveShellComponent implements OnDestroy { return; } - if (data.path != currentPath) { - this.#store.setPath(data.path); + // The tree tells its site row apart from a folder by giving it an empty path. As a + // *location* that means the site root, `/`, which is a different thing from all site + // content — and all site content is what an absent location means. Translating here keeps + // the tree's own representation untouched while stopping the two collapsing into one. + const location = data.path === '' ? ROOT_PATH : data.path; + + if (location != currentPath) { + this.#store.setPath(location); } }); diff --git a/core-web/libs/portlets/dot-content-drive/portlet/src/lib/store/dot-content-drive.store.ts b/core-web/libs/portlets/dot-content-drive/portlet/src/lib/store/dot-content-drive.store.ts index 77b4e1b933e2..264b00f0ed47 100644 --- a/core-web/libs/portlets/dot-content-drive/portlet/src/lib/store/dot-content-drive.store.ts +++ b/core-web/libs/portlets/dot-content-drive/portlet/src/lib/store/dot-content-drive.store.ts @@ -46,6 +46,7 @@ import { SHARED_ASSETS_DISABLED_VALUE, SHARED_ASSETS_FILTER_KEY, SYSTEM_HOST, + SYSTEM_HOST_PATH, USER_SEARCHABLE_PREFIX } from '../shared/constants'; import { @@ -668,7 +669,10 @@ export const DotContentDriveStore = signalStore( * Derived from the location rather than stored beside it: an absent location *is* what all * site content means, so a second piece of state saying so could only ever disagree. */ - $allSiteContentSelected: computed(() => !path()) + $allSiteContentSelected: computed(() => !path()), + + /** Whether the sidebar's last entry, System Host, is the selected one. */ + $systemHostSelected: computed(() => path() === SYSTEM_HOST_PATH) })), withComputed(({ selectedNode, siteCanAddChildren, $allSiteContentSelected }) => ({ /** diff --git a/core-web/libs/portlets/dot-content-drive/portlet/src/lib/store/features/sidebar/withSidebar.ts b/core-web/libs/portlets/dot-content-drive/portlet/src/lib/store/features/sidebar/withSidebar.ts index 34e34b0302e3..ba33a29886f2 100644 --- a/core-web/libs/portlets/dot-content-drive/portlet/src/lib/store/features/sidebar/withSidebar.ts +++ b/core-web/libs/portlets/dot-content-drive/portlet/src/lib/store/features/sidebar/withSidebar.ts @@ -16,7 +16,12 @@ import { catchError } from 'rxjs/operators'; import { DotFolderService } from '@dotcms/data-access'; import { DotFolderTreeNodeItem } from '@dotcms/portlets/content-drive/ui'; -import { DEFAULT_PAGE, DEFAULT_PATH, SYSTEM_HOST } from '../../../shared/constants'; +import { + DEFAULT_PAGE, + DEFAULT_PATH, + SYSTEM_HOST, + SYSTEM_HOST_PATH +} from '../../../shared/constants'; import { DotContentDriveState } from '../../../shared/models'; import { applyLoadMoreToHierarchy, @@ -117,7 +122,11 @@ export function withSidebar() { // nothing, and expanding it fetched them a second time — the // tree showed every root folder twice. folders: [{ ...siteNode, children: rootsWithLoadMore }], - selectedNode: selectedNode + // No location means all site content, which is not a place in + // the hierarchy. Preselecting the site row there would have the + // sidebar claiming the root is what you are looking at, and the + // root and the flat whole-site view are different things. + selectedNode: urlFolderPath ? selectedNode : undefined }); }) ); @@ -178,6 +187,20 @@ export function withSidebar() { }); }, + /** + * Selects System Host: shared content on its own, which belongs to no site and so has + * no place in the hierarchy either. Same shape as choosing all site content — one + * entry selected, the tree's own selection cleared. + */ + selectSystemHost: () => { + patchState(store, { + path: SYSTEM_HOST_PATH, + selectedNode: undefined, + pagination: { ...store.pagination(), page: 1, offset: 0 }, + pages: [DEFAULT_PAGE] + }); + }, + /** * Selects the tree's root row, the one that stands for the site rather than a folder. * diff --git a/core-web/libs/ui/src/lib/components/dot-folder-list-view/dot-folder-list-view.component.html b/core-web/libs/ui/src/lib/components/dot-folder-list-view/dot-folder-list-view.component.html index 37d6de7ed3c9..6ee0d180435c 100644 --- a/core-web/libs/ui/src/lib/components/dot-folder-list-view/dot-folder-list-view.component.html +++ b/core-web/libs/ui/src/lib/components/dot-folder-list-view/dot-folder-list-view.component.html @@ -176,11 +176,12 @@ column, and only shared rows render it. The wording went through the full sentence (crowded the row), "Shared" alone (reads as shared with other USERS) and "All Sites" (unambiguous - about scope, but never says what it is describing). "Shared Asset" is the - term the toolbar's own filter uses -- "Show Shared Assets" sits directly - above this table -- so the label is defined by the control the user just - operated, which disambiguates it better than any rewording can. The full - sentence stays on hover. --> + about scope, but never says what it is describing). What settled it is + that the label should name whatever the toolbar's own filter names, since + that control sits directly above this table and is what the user just + operated. That filter is now "Show System Host", and the sidebar carries + a System Host entry beside it, so this follows both rather than drifting + into a third word for one thing. The full sentence stays on hover. --> Date: Mon, 14 Sep 2026 15:05:49 -0300 Subject: [PATCH 18/44] fix(browser): make both query builders name the same hosts (#37426) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The index builder widened its host clause to admit System Host whenever the folder happened to be the system folder — that is, at every site root — no matter what the caller asked for. The SQL builder never did. So one request got two different answers about a structural criterion depending on which heuristic served it. ADR-0018 makes the database authoritative for exactly those criteria, parent folder and site with System Host included, and says they must never be silently re-routed to the index. Two builders disagreeing about one is the failure that decision exists to prevent, which is what makes this a correctness fix rather than tidying. It only ever surfaced under the non-default PURE_ES heuristic, which explains why it went unnoticed, not why it was acceptable. The last test states the point directly: for one request, both builders name the same hosts. Asserted on the clause each emits rather than on results, because PURE_ES gives up read-your-writes, so comparing returned content would be comparing two different moments and would flake in a way that reads like a scope bug. Red first: 2 of 5 failing, on the disagreement itself. Co-Authored-By: Claude Opus 5 --- .../com/dotcms/browser/BrowserAPIImpl.java | 13 +- .../BrowserQueryIndexHostClauseTest.java | 179 ++++++++++++++++++ 2 files changed, 187 insertions(+), 5 deletions(-) create mode 100644 dotCMS/src/test/java/com/dotcms/browser/BrowserQueryIndexHostClauseTest.java diff --git a/dotCMS/src/main/java/com/dotcms/browser/BrowserAPIImpl.java b/dotCMS/src/main/java/com/dotcms/browser/BrowserAPIImpl.java index 8ee68b8ca150..ec1f8e983c82 100644 --- a/dotCMS/src/main/java/com/dotcms/browser/BrowserAPIImpl.java +++ b/dotCMS/src/main/java/com/dotcms/browser/BrowserAPIImpl.java @@ -643,13 +643,16 @@ private String buildPureESQuery(final BrowserQuery browserQuery) { ? browserQuery.site.getIdentifier() : browserQuery.folder.getHostId(); + // The caller's request decides this, and nothing else. This used to widen the clause + // whenever the folder happened to be the system folder — that is, at every site root — + // which made this builder answer differently from the SQL one about a structural + // criterion. ADR-0018 makes the database authoritative for exactly those criteria and + // forbids re-routing them to the index, so the two must name the same hosts for the same + // request. The divergence only ever surfaced under the non-default PURE_ES heuristic, + // which is why it went unnoticed rather than why it was acceptable. if (SystemHostMode.ONLY == browserQuery.systemHostMode) { query.append("+conhost:SYSTEM_HOST "); - } else if (SystemHostMode.INCLUDE == browserQuery.systemHostMode - || browserQuery.folder.isSystemFolder()) { - // The system-folder half of this condition is deliberately preserved for now: it makes - // this builder disagree with the SQL one about a structural criterion, which is a - // defect in its own right and is fixed with its own test rather than silently here. + } else if (SystemHostMode.INCLUDE == browserQuery.systemHostMode) { query.append("+(conhost:").append(hostId).append(" OR conhost:SYSTEM_HOST) "); } else { query.append("+conhost:").append(hostId).append(" "); diff --git a/dotCMS/src/test/java/com/dotcms/browser/BrowserQueryIndexHostClauseTest.java b/dotCMS/src/test/java/com/dotcms/browser/BrowserQueryIndexHostClauseTest.java new file mode 100644 index 000000000000..c7413bb0717f --- /dev/null +++ b/dotCMS/src/test/java/com/dotcms/browser/BrowserQueryIndexHostClauseTest.java @@ -0,0 +1,179 @@ +package com.dotcms.browser; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; +import static org.mockito.Mockito.CALLS_REAL_METHODS; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import com.dotmarketing.beans.Host; +import com.dotmarketing.portlets.folders.model.Folder; +import java.lang.reflect.Field; +import java.lang.reflect.Method; +import java.util.List; +import java.util.Set; +import org.junit.Test; + +/** + * The host clause the index query carries, and that it agrees with the one the SQL + * carries. + * + *

Two builders answer the same question about the same request. ADR-0018 makes the database + * authoritative for the structural criteria — parent folder and site, System Host included — and + * says such criteria "must never be silently re-routed to the index". Two builders disagreeing + * about one of them is the failure that decision exists to prevent, so this is a correctness fix + * rather than tidying.

+ * + *

The disagreement was narrow and easy to miss: the index builder widened the clause to admit + * System Host whenever the folder happened to be the system folder, no matter what the caller + * asked for. It only surfaces under the non-default {@code PURE_ES} heuristic, which is why it + * has gone unnoticed, and why it is pinned here rather than left to an integration run.

+ * + *

Exercised through the real query-building method by reflection, with no {@code APILocator} + * bootstrap and no database, following {@link BrowserAPIMimeTypeQueryTest}.

+ */ +public class BrowserQueryIndexHostClauseTest { + + private static final String SITE_ID = "48190c8c-42c4-46af-8d1a-0cd5db894797"; + + /** + * Given a caller that says nothing about System Host, When the index query is built at the + * site root, Then it matches that site alone. + *

+ * This is the case that was wrong. The site root resolves to the system folder, and the + * builder used to read that as a reason to admit System Host regardless of the request. + */ + @Test + public void testAtTheSiteRootSayingNothingStillMatchesThatSiteAlone() throws Exception { + final String query = buildIndexQuery(SystemHostMode.EXCLUDE, true); + + assertTrue("the site must be matched", query.contains("+conhost:" + SITE_ID)); + assertFalse("being at the root is not a request for shared content: " + query, + query.contains("SYSTEM_HOST")); + } + + /** + * Given a caller asking for shared content, When the index query is built, Then the clause + * widens to admit System Host alongside the site. + */ + @Test + public void testAskingForSharedContentAdmitsSystemHostAlongsideTheSite() throws Exception { + final String query = buildIndexQuery(SystemHostMode.INCLUDE, true); + + assertTrue("both must be admitted: " + query, + query.contains("+(conhost:" + SITE_ID + " OR conhost:SYSTEM_HOST)")); + } + + /** + * Given a caller asking for System Host alone, When the index query is built, Then the site is + * not matched at all — it is context for the request, not a filter on it. + */ + @Test + public void testAskingForSystemHostAloneDoesNotMatchTheSite() throws Exception { + final String query = buildIndexQuery(SystemHostMode.ONLY, true); + + assertTrue("System Host must be matched", query.contains("+conhost:SYSTEM_HOST")); + assertFalse("the site must not be matched as well: " + query, + query.contains(SITE_ID)); + } + + /** + * Inside a folder the two builders already agreed, and must keep agreeing: the site is matched + * and nothing else is admitted. + */ + @Test + public void testInsideAFolderTheSiteAloneIsMatched() throws Exception { + final String query = buildIndexQuery(SystemHostMode.EXCLUDE, false); + + assertTrue("the site must be matched", query.contains("+conhost:" + SITE_ID)); + assertFalse("nothing else may be admitted: " + query, query.contains("SYSTEM_HOST")); + } + + /** + * The point of the whole exercise, stated directly: for one request, both builders name the + * same hosts. Asserted on the clause each emits rather than on results, because the index + * builder runs under a heuristic that gives up read-your-writes (ADR-0018), so comparing + * returned content would be comparing two different moments. + */ + @Test + public void testBothBuildersNameTheSameHostsForTheSameRequest() throws Exception { + for (final SystemHostMode mode : SystemHostMode.values()) { + final String indexQuery = buildIndexQuery(mode, true); + final String sql = buildSelect(mode); + + final boolean indexAdmitsSystemHost = indexQuery.contains("SYSTEM_HOST"); + final boolean sqlAdmitsSystemHost = sql.contains("SYSTEM_HOST"); + assertEquals(mode + ": the two builders must agree about System Host", + sqlAdmitsSystemHost, indexAdmitsSystemHost); + + final boolean indexMatchesSite = indexQuery.contains(SITE_ID); + // The SQL binds the site as a parameter rather than inlining it, so its equivalent of + // "matches the site" is the presence of the bound predicate. + final boolean sqlMatchesSite = sql.contains("id.host_inode = ?"); + assertEquals(mode + ": the two builders must agree about the site", + sqlMatchesSite, indexMatchesSite); + } + } + + private static String buildIndexQuery(final SystemHostMode mode, final boolean atSiteRoot) + throws Exception { + final BrowserAPIImpl api = mock(BrowserAPIImpl.class, CALLS_REAL_METHODS); + final BrowserQuery query = baseQuery(mode, atSiteRoot); + + final Method method = + BrowserAPIImpl.class.getDeclaredMethod("buildPureESQuery", BrowserQuery.class); + method.setAccessible(true); + + return (String) method.invoke(api, query); + } + + private static String buildSelect(final SystemHostMode mode) throws Exception { + final BrowserAPIImpl api = mock(BrowserAPIImpl.class, CALLS_REAL_METHODS); + final BrowserQuery query = baseQuery(mode, true); + + final Method method = + BrowserAPIImpl.class.getDeclaredMethod("selectQuery", BrowserQuery.class); + method.setAccessible(true); + final BrowserAPIImpl.SelectQuery built = + (BrowserAPIImpl.SelectQuery) method.invoke(api, query); + + return built.selectQuery; + } + + private static BrowserQuery baseQuery(final SystemHostMode mode, final boolean atSiteRoot) + throws Exception { + final BrowserQuery query = mock(BrowserQuery.class, CALLS_REAL_METHODS); + + for (final String emptyCollection : List.of("languageIds", "contentTypeIds", + "excludedContentTypeIds", "workflowSchemeIds", "workflowStepIds", + "contentStatuses")) { + setField(query, emptyCollection, Set.of()); + } + setField(query, "baseTypes", Set.of()); + setField(query, "fieldCriteria", List.of()); + setField(query, "mimeTypes", List.of()); + setField(query, "systemHostMode", mode); + + // Mocked rather than constructed: `new Host()` resolves its content type through the + // legacy cache, which calls APILocator.systemUser() and reaches for a database connection. + final Host site = mock(Host.class); + when(site.getIdentifier()).thenReturn(SITE_ID); + setField(query, "site", site); + + final Folder folder = mock(Folder.class); + when(folder.isSystemFolder()).thenReturn(atSiteRoot); + when(folder.getPath()).thenReturn(atSiteRoot ? "/" : "/application/"); + when(folder.getHostId()).thenReturn(SITE_ID); + setField(query, "folder", folder); + + return query; + } + + private static void setField(final BrowserQuery query, final String name, final Object value) + throws Exception { + final Field field = BrowserQuery.class.getDeclaredField(name); + field.setAccessible(true); + field.set(query, value); + } +} From 65bb3cfecc8f9f5175dd069c2e036b66ec951503 Mon Sep 17 00:00:00 2001 From: Jalinson Diaz Date: Mon, 14 Sep 2026 15:39:56 -0300 Subject: [PATCH 19/44] feat(dotcms-ui): let content actually land on System Host (#37426) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Upload, drag and drop, and New each answered "where would this go" separately, and each fell back to the current site when no folder was selected. That is right everywhere except System Host, where the site in the switcher is context rather than the destination. New was worse than wrong: it built its target by pasting the location onto the hostname, so the reserved word produced "demo.dotcms.comSYSTEM_HOST", which resolves to nothing. One computed now answers for all three, and a selected folder still wins over it as the more specific answer. The permission gate was answering for the wrong host too. The site lookup filters System Host out deliberately — there it arrives as the seed the drive holds before a real site resolves, and answering for it would answer about the wrong asset. Now the same identifier can also mean the destination the user chose, so it gets its own lookup, loaded once because System Host belongs to no site. Kept apart from the site's answer rather than folded in: that one is reset on every site switch, and sharing the slot would discard an answer that had not changed. Two upload tests asserted the old fallback. Rather than pinning a new literal, the store mock now mirrors the real computed, so they keep driving the destination through the signals they already control. 1382 tests pass. Co-Authored-By: Claude Opus 5 --- ...og-content-type-selector.component.spec.ts | 8 +- ...-dialog-content-type-selector.component.ts | 8 ++ .../dot-content-drive-shell.component.spec.ts | 13 ++- .../dot-content-drive-shell.component.ts | 9 +- .../src/lib/store/dot-content-drive.store.ts | 91 +++++++++++++------ .../site-permissions/withSitePermissions.ts | 41 ++++++++- 6 files changed, 133 insertions(+), 37 deletions(-) diff --git a/core-web/libs/portlets/dot-content-drive/portlet/src/lib/components/dialogs/dot-content-drive-dialog-content-type-selector/dot-content-drive-dialog-content-type-selector.component.spec.ts b/core-web/libs/portlets/dot-content-drive/portlet/src/lib/components/dialogs/dot-content-drive-dialog-content-type-selector/dot-content-drive-dialog-content-type-selector.component.spec.ts index 838a5bd8d292..89cab996098b 100644 --- a/core-web/libs/portlets/dot-content-drive/portlet/src/lib/components/dialogs/dot-content-drive-dialog-content-type-selector/dot-content-drive-dialog-content-type-selector.component.spec.ts +++ b/core-web/libs/portlets/dot-content-drive/portlet/src/lib/components/dialogs/dot-content-drive-dialog-content-type-selector/dot-content-drive-dialog-content-type-selector.component.spec.ts @@ -8,6 +8,8 @@ import { import { MockComponent } from 'ng-mocks'; import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { signal } from '@angular/core'; + import { DotMessageService } from '@dotcms/data-access'; import { DOT_PALETTE_PERSIST_PREFERENCES, @@ -23,6 +25,9 @@ import { DotContentDriveStore } from '../../../store/dot-content-drive.store'; const SELECTED_VARIABLE = 'Blog'; +// Real signal: the component reads it in a computed, and a vi.fn cannot invalidate one. +const systemHostSelected = signal(false); + describe('DotContentDriveDialogContentTypeSelectorComponent', () => { let spectator: Spectator; let store: SpyObject>; @@ -48,7 +53,8 @@ describe('DotContentDriveDialogContentTypeSelectorComponent', () => { path: vi.fn().mockReturnValue('/about-us/'), selectedNode: vi .fn() - .mockReturnValue({ data: { type: 'folder', inode: 'inode-1' } }) + .mockReturnValue({ data: { type: 'folder', inode: 'inode-1' } }), + $systemHostSelected: systemHostSelected }), mockProvider(DotContentDriveNavigationService, { createContent: vi.fn() diff --git a/core-web/libs/portlets/dot-content-drive/portlet/src/lib/components/dialogs/dot-content-drive-dialog-content-type-selector/dot-content-drive-dialog-content-type-selector.component.ts b/core-web/libs/portlets/dot-content-drive/portlet/src/lib/components/dialogs/dot-content-drive-dialog-content-type-selector/dot-content-drive-dialog-content-type-selector.component.ts index a6dc1c098cf7..7bc743aa69a5 100644 --- a/core-web/libs/portlets/dot-content-drive/portlet/src/lib/components/dialogs/dot-content-drive-dialog-content-type-selector/dot-content-drive-dialog-content-type-selector.component.ts +++ b/core-web/libs/portlets/dot-content-drive/portlet/src/lib/components/dialogs/dot-content-drive-dialog-content-type-selector/dot-content-drive-dialog-content-type-selector.component.ts @@ -12,6 +12,7 @@ import { } from '@dotcms/portlets/dot-ema/ui'; import { DotMessagePipe } from '@dotcms/ui'; +import { SYSTEM_HOST } from '../../../shared/constants'; import { DotContentDriveNavigationService } from '../../../shared/services/dot-content-drive-navigation.service'; import { DotContentDriveStore } from '../../../store/dot-content-drive.store'; @@ -70,6 +71,13 @@ export class DotContentDriveDialogContentTypeSelectorComponent { * At the site root both fall back to the current site (empty path / no inode). */ #getCurrentFolder(): { folderPath?: string; folderInode?: string } { + // System Host is a destination in its own right, and the site in the switcher is only + // context while it is selected. Pasting the location onto the hostname would also produce + // `demo.dotcms.comSYSTEM_HOST` for the reserved word, which resolves to nothing. + if (this.#store.$systemHostSelected()) { + return { folderInode: SYSTEM_HOST.identifier }; + } + const hostname = this.#store.currentSite()?.hostname; const path = this.#store.path(); const data = this.#store.selectedNode()?.data; diff --git a/core-web/libs/portlets/dot-content-drive/portlet/src/lib/dot-content-drive-shell/dot-content-drive-shell.component.spec.ts b/core-web/libs/portlets/dot-content-drive/portlet/src/lib/dot-content-drive-shell/dot-content-drive-shell.component.spec.ts index 8875192053ef..d535d4dd82f4 100644 --- a/core-web/libs/portlets/dot-content-drive/portlet/src/lib/dot-content-drive-shell/dot-content-drive-shell.component.spec.ts +++ b/core-web/libs/portlets/dot-content-drive/portlet/src/lib/dot-content-drive-shell/dot-content-drive-shell.component.spec.ts @@ -215,6 +215,9 @@ describe('DotContentDriveShellComponent', () => { showInListFieldsSignal = signal([]); editPanelRequestSignal.set(null); + const currentSiteMock = vi.fn().mockReturnValue(MOCK_SITES[0]); + const systemHostSelectedMock = vi.fn().mockReturnValue(false); + spectator = createComponent({ providers: [ mockProvider(DotContentDriveStore, { @@ -223,7 +226,7 @@ describe('DotContentDriveShellComponent', () => { // their creation affordances on it. $canAddChildren: canAddChildrenSignal, siteCanAddChildren: siteCanAddChildrenSignal, - currentSite: vi.fn().mockReturnValue(MOCK_SITES[0]), + currentSite: currentSiteMock, // Tree collapsed at start to render the toggle button on toolbar isTreeExpanded: vi.fn().mockReturnValue(false), removeFilter: vi.fn(), @@ -277,7 +280,13 @@ describe('DotContentDriveShellComponent', () => { setSelectedNode: vi.fn(), // The shell renders the sidebar, which asks the store which entry is selected. $allSiteContentSelected: vi.fn().mockReturnValue(false), - $systemHostSelected: vi.fn().mockReturnValue(false), + $systemHostSelected: systemHostSelectedMock, + // Mirrors the store's own computed rather than hardcoding an answer, so these + // tests keep driving the destination through the signals they already control: + // System Host when that is selected, the current site otherwise. + $newContentHostId: vi.fn(() => + systemHostSelectedMock() ? 'SYSTEM_HOST' : currentSiteMock()?.identifier + ), selectAllSiteContent: vi.fn(), selectSystemHost: vi.fn(), sidebarLoading: vi.fn(), diff --git a/core-web/libs/portlets/dot-content-drive/portlet/src/lib/dot-content-drive-shell/dot-content-drive-shell.component.ts b/core-web/libs/portlets/dot-content-drive/portlet/src/lib/dot-content-drive-shell/dot-content-drive-shell.component.ts index bf687be05d47..2b9f2c1d0121 100644 --- a/core-web/libs/portlets/dot-content-drive/portlet/src/lib/dot-content-drive-shell/dot-content-drive-shell.component.ts +++ b/core-web/libs/portlets/dot-content-drive/portlet/src/lib/dot-content-drive-shell/dot-content-drive-shell.component.ts @@ -1201,10 +1201,11 @@ export class DotContentDriveShellComponent implements OnDestroy { protected uploadByBaseType(file: File, baseType: string, hostFolder?: DotFolderTreeNodeData) { this.#fileService .uploadFileByBaseType(file, baseType, { - // A folder id carries its site; at the site root (no folder) fall back to the - // current site identifier so the upload lands on the site being browsed, not the - // backend default host. - hostFolder: hostFolder?.id ?? this.#store.currentSite()?.identifier ?? '', + // A folder id carries its site, so a selected folder is the most specific answer + // and wins. With no folder the destination is whichever host the sidebar is + // showing — the site being browsed, or System Host when that is what is selected, + // which is not the same as the site in the switcher. + hostFolder: hostFolder?.id ?? this.#store.$newContentHostId() ?? '', indexPolicy: 'WAIT_FOR' }) .subscribe({ diff --git a/core-web/libs/portlets/dot-content-drive/portlet/src/lib/store/dot-content-drive.store.ts b/core-web/libs/portlets/dot-content-drive/portlet/src/lib/store/dot-content-drive.store.ts index 264b00f0ed47..8ea33cb0d08a 100644 --- a/core-web/libs/portlets/dot-content-drive/portlet/src/lib/store/dot-content-drive.store.ts +++ b/core-web/libs/portlets/dot-content-drive/portlet/src/lib/store/dot-content-drive.store.ts @@ -674,49 +674,82 @@ export const DotContentDriveStore = signalStore( /** Whether the sidebar's last entry, System Host, is the selected one. */ $systemHostSelected: computed(() => path() === SYSTEM_HOST_PATH) })), - withComputed(({ selectedNode, siteCanAddChildren, $allSiteContentSelected }) => ({ + withComputed(({ currentSite, $systemHostSelected }) => ({ /** - * Whether the browsed folder accepts new children. + * The host that would receive new content here. * - * A new folder needs CAN_ADD_CHILDREN on the parent (`FolderAPIImpl:673`) and moving a - * contentlet needs it on the destination (`ESContentletAPIImpl:607`). An **upload does not**: - * the contentlet checkin path never checks it, so that one is gated here for consistency - * rather than as a preview of a refusal — otherwise uploading would quietly allow what - * creating a folder in the same place forbids. + * Three paths ask this and used to answer it separately: the upload button, a drag and + * drop, and the New menu. Each fell back to the current site when no folder was selected, + * which is right everywhere except System Host, where the current site is context rather + * than the destination. The New menu was worse than wrong — it built its target by pasting + * the location onto the hostname, which with a reserved word yields + * `demo.dotcms.comSYSTEM_HOST` and resolves to nothing. * - * Computed here rather than in each consumer because three surfaces gate on it — the New - * menu, the Upload button and the drop zone — and three copies of the folder-then-site - * fallback would be three chances to disagree. - * - * A node with no permissions is the site root, whose parent is the host rather than a - * folder; `siteCanAddChildren` answers that case. Both unknowns read as allowed: a lookup - * in flight, and an instance too old to report the field. Starting disabled would flicker - * the affordances off and on for the common case, and the server refuses the write anyway. + * A folder, when one is selected, is still more specific than this and wins. */ - $canAddChildren: computed(() => { - // All site content spans every folder in the site, so there is no single place for - // new content to land. This is not a permission answer and it is not negotiable by - // one: the question is not whether the user may add content, it is where it would go. - if ($allSiteContentSelected()) { - return false; - } + $newContentHostId: computed(() => + $systemHostSelected() ? SYSTEM_HOST.identifier : currentSite()?.identifier + ) + })), + withComputed( + ({ + selectedNode, + siteCanAddChildren, + systemHostCanAddChildren, + $allSiteContentSelected, + $systemHostSelected + }) => ({ + /** + * Whether the browsed folder accepts new children. + * + * A new folder needs CAN_ADD_CHILDREN on the parent (`FolderAPIImpl:673`) and moving a + * contentlet needs it on the destination (`ESContentletAPIImpl:607`). An **upload does not**: + * the contentlet checkin path never checks it, so that one is gated here for consistency + * rather than as a preview of a refusal — otherwise uploading would quietly allow what + * creating a folder in the same place forbids. + * + * Computed here rather than in each consumer because three surfaces gate on it — the New + * menu, the Upload button and the drop zone — and three copies of the folder-then-site + * fallback would be three chances to disagree. + * + * A node with no permissions is the site root, whose parent is the host rather than a + * folder; `siteCanAddChildren` answers that case. Both unknowns read as allowed: a lookup + * in flight, and an instance too old to report the field. Starting disabled would flicker + * the affordances off and on for the common case, and the server refuses the write anyway. + */ + $canAddChildren: computed(() => { + // All site content spans every folder in the site, so there is no single place for + // new content to land. This is not a permission answer and it is not negotiable by + // one: the question is not whether the user may add content, it is where it would go. + if ($allSiteContentSelected()) { + return false; + } - const permissions = (selectedNode()?.data as { permissions?: string[] } | undefined) - ?.permissions; + // System Host is a real destination, so this is a permission answer again — but about + // System Host, not about whichever site the switcher happens to show. + if ($systemHostSelected()) { + return systemHostCanAddChildren() !== false; + } - if (!permissions?.length) { - return siteCanAddChildren() !== false; - } + const permissions = (selectedNode()?.data as { permissions?: string[] } | undefined) + ?.permissions; - return permissions.includes(PERMISSIONS_TYPE.CAN_ADD_CHILDREN); + if (!permissions?.length) { + return siteCanAddChildren() !== false; + } + + return permissions.includes(PERMISSIONS_TYPE.CAN_ADD_CHILDREN); + }) }) - })), + ), withHooks((store) => ({ onInit() { // Fed the signal rather than called on each site change: `rxMethod` re-runs on every // emission and `switchMap` drops the previous site's in-flight answer, so switching // sites quickly can never settle the gate with the wrong site's result. store.loadSitePermissions(store.currentSite); + // Once, not per site: System Host belongs to none of them. + store.loadSystemHostPermissions(); } })) ); diff --git a/core-web/libs/portlets/dot-content-drive/portlet/src/lib/store/features/site-permissions/withSitePermissions.ts b/core-web/libs/portlets/dot-content-drive/portlet/src/lib/store/features/site-permissions/withSitePermissions.ts index 2eae6f2115c9..36250af749aa 100644 --- a/core-web/libs/portlets/dot-content-drive/portlet/src/lib/store/features/site-permissions/withSitePermissions.ts +++ b/core-web/libs/portlets/dot-content-drive/portlet/src/lib/store/features/site-permissions/withSitePermissions.ts @@ -24,6 +24,16 @@ interface WithSitePermissionsState { * inherits from SYSTEM_HOST and so answers identically no matter which site is open. */ siteCanAddChildren: boolean | undefined; + + /** + * CAN_ADD_CHILDREN on System Host itself. + * + * Kept apart from {@link siteCanAddChildren} rather than folded into it. That one is reset and + * re-fetched every time the site changes, and System Host belongs to no site, so sharing the + * slot would have each switch throw away an answer that had not changed and briefly ungate + * the affordances for a destination nobody navigated away from. + */ + systemHostCanAddChildren: boolean | undefined; } /** @@ -37,7 +47,8 @@ export function withSitePermissions() { // the only slice it touches, so nothing here needs the host store's shape. return signalStoreFeature( withState({ - siteCanAddChildren: undefined + siteCanAddChildren: undefined, + systemHostCanAddChildren: undefined }), withMethods((store, dotPermissionsService = inject(DotPermissionsService)) => ({ /** @@ -49,6 +60,34 @@ export function withSitePermissions() { * the permission. Push Publish disables on failure because offering a push with nowhere * to send it fails later and less legibly. */ + /** + * Looks up CAN_ADD_CHILDREN on System Host. + * + * Separate from the site lookup, which filters System Host out deliberately: there it + * arrives as the seed the drive holds before a real site resolves, and answering for + * it would answer about the wrong asset. Here it is the destination the user chose, so + * the same identifier means the opposite thing and needs its own way in. + * + * Same failure posture as the site lookup: a transient error settles on allowed, since + * this only softens an affordance the server still guards. + */ + loadSystemHostPermissions: rxMethod( + pipe( + switchMap(() => + dotPermissionsService.canAddChildren(SYSTEM_HOST.identifier).pipe( + tapResponse({ + next: (canAddChildren) => + patchState(store, { + systemHostCanAddChildren: canAddChildren + }), + error: () => + patchState(store, { systemHostCanAddChildren: true }) + }) + ) + ) + ) + ), + loadSitePermissions: rxMethod( pipe( // SYSTEM_HOST is the seed the drive holds before a real site resolves, and it From a182590ea5bd5e20d2cf9f378cf834e70354b95d Mon Sep 17 00:00:00 2001 From: Jalinson Diaz Date: Tue, 15 Sep 2026 09:33:49 -0300 Subject: [PATCH 20/44] fix(browser): point the integration tests at the three-state (#37426) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replacing the `forceSystemHost` boolean with the include/exclude/only state removed the builder method three callers in `BrowserAPITest` still use, and the PR build has been failing on it since. All three passed `false`, which is the new default, so they become `SystemHostMode.EXCLUDE` rather than disappearing — each one is deliberately explicit about the scope it wants and should stay that way. Missed locally because `dotcms-integration` is not in the reactor that `-pl :dotcms-core --am` builds, so compiling core said nothing about it. Caught here by compiling the integration module's test sources directly, which is worth doing for any change to a shared builder. Co-Authored-By: Claude Opus 5 (1M context) --- .../src/test/java/com/dotcms/browser/BrowserAPITest.java | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/dotcms-integration/src/test/java/com/dotcms/browser/BrowserAPITest.java b/dotcms-integration/src/test/java/com/dotcms/browser/BrowserAPITest.java index f543fa6e8b50..86111ae9a601 100644 --- a/dotcms-integration/src/test/java/com/dotcms/browser/BrowserAPITest.java +++ b/dotcms-integration/src/test/java/com/dotcms/browser/BrowserAPITest.java @@ -1965,7 +1965,7 @@ public void test_getContent_Using_LimitedUser_WithRead_Permissions() throws Exce .ignoreSiteForFolders(true) .respectFrontEndRoles(false) // <-- This is key for this test! .withUser(limitedUser) - .forceSystemHost(false) + .systemHostMode(SystemHostMode.EXCLUDE) .showContent(true) .showFiles(false) .showFolders(false) @@ -2065,7 +2065,7 @@ public void test_exhaustive_pagination_with_permission_filtering() throws Except .ignoreSiteForFolders(true) .respectFrontEndRoles(false) .withUser(limitedUser) - .forceSystemHost(false) + .systemHostMode(SystemHostMode.EXCLUDE) .showContent(true) .contentCursor(0) .showFiles(false) @@ -3026,7 +3026,7 @@ public void test_getPaginatedContents_folderScopedCte_permissionScopingUnchanged .ignoreSiteForFolders(true) .respectFrontEndRoles(false) .withUser(limitedUser) - .forceSystemHost(false) + .systemHostMode(SystemHostMode.EXCLUDE) .showFiles(true) .showWorking(true) .build(); From 9c00530ef067b2f86c63499c64bfe335bfadcaf9 Mon Sep 17 00:00:00 2001 From: Jalinson Diaz Date: Tue, 15 Sep 2026 11:26:34 -0300 Subject: [PATCH 21/44] fix(content-drive): name System Host as the upload destination (#37426) With System Host selected and no folder chosen, the progress indicator read the site in the switcher -- so it told the author their files were going to demo.dotcms.com while they went to System Host. The switcher still names a site in that scope; the site is context there, not the destination. Both places that start an upload run now ask one helper, which answers the same question the destination itself asks and uses the sidebar entry's own label, so the indicator and the row the user clicked agree. Red first: the test failed with `targetLabel: "demo.com"` where "System Host" belonged. Co-Authored-By: Claude Opus 5 (1M context) --- .../dot-content-drive-shell.component.spec.ts | 19 +++++++++++++ .../dot-content-drive-shell.component.ts | 27 ++++++++++++++++--- 2 files changed, 42 insertions(+), 4 deletions(-) diff --git a/core-web/libs/portlets/dot-content-drive/portlet/src/lib/dot-content-drive-shell/dot-content-drive-shell.component.spec.ts b/core-web/libs/portlets/dot-content-drive/portlet/src/lib/dot-content-drive-shell/dot-content-drive-shell.component.spec.ts index b3c1a6a82a67..a1b79a8e2754 100644 --- a/core-web/libs/portlets/dot-content-drive/portlet/src/lib/dot-content-drive-shell/dot-content-drive-shell.component.spec.ts +++ b/core-web/libs/portlets/dot-content-drive/portlet/src/lib/dot-content-drive-shell/dot-content-drive-shell.component.spec.ts @@ -2097,6 +2097,25 @@ describe('DotContentDriveShellComponent', () => { ); }); + it('should name System Host as the upload destination, not the switcher site', () => { + // The indicator is the only surface an upload has before the handle comes back, so a + // wrong destination there tells the author their files are going somewhere else. + store.currentSite.mockReturnValue(MOCK_SITES[0]); + store.$systemHostSelected.mockReturnValue(true); + + selectUploadType({ + targetFolder: undefined, + files: createFileList([createFile('a.png')]), + baseType: 'DOTASSET' + }); + + // The message mock echoes keys, so the key IS the observable outcome here: the + // indicator asks for the sidebar entry's own label rather than naming a site. + expect(store.startExternalRun).toHaveBeenCalledWith( + expect.objectContaining({ targetLabel: 'content-drive.sidebar.system-host' }) + ); + }); + it('should target System Host when that is where the batch lands, not the switcher site', () => { // The switcher still names a site while System Host is browsed, and that site is // context rather than the destination. Uploading here with the site's identifier diff --git a/core-web/libs/portlets/dot-content-drive/portlet/src/lib/dot-content-drive-shell/dot-content-drive-shell.component.ts b/core-web/libs/portlets/dot-content-drive/portlet/src/lib/dot-content-drive-shell/dot-content-drive-shell.component.ts index 72aa1b91bd4d..ea515f07f5ad 100644 --- a/core-web/libs/portlets/dot-content-drive/portlet/src/lib/dot-content-drive-shell/dot-content-drive-shell.component.ts +++ b/core-web/libs/portlets/dot-content-drive/portlet/src/lib/dot-content-drive-shell/dot-content-drive-shell.component.ts @@ -1465,6 +1465,27 @@ export class DotContentDriveShellComponent implements OnDestroy { * @param {DotFolderTreeNodeData} [hostFolder] * @memberof DotContentDriveShellComponent */ + /** + * What the progress indicator calls an upload's destination. + * + * `||`, not `??`: the site root's node carries an *empty* path, which is present but names + * nothing, so the indicator would otherwise read "Applying Upload to " with a blank target. + * + * With no folder the destination is whichever host the sidebar is showing — and in the System + * Host scope that is not the site in the switcher, which still names one. Saying the site's + * name there tells the author their files are going somewhere they are not. The name is the + * sidebar entry's own label, so the indicator and the row the user clicked agree. + */ + private uploadTargetLabel(hostFolder?: { path?: string }): string | undefined { + if (hostFolder?.path) { + return hostFolder.path; + } + + return this.#store.$systemHostSelected() + ? this.#dotMessageService.get('content-drive.sidebar.system-host') + : this.#store.currentSite()?.hostname; + } + protected uploadByBaseType( files: File[], baseType: string, @@ -1505,9 +1526,7 @@ export class DotContentDriveShellComponent implements OnDestroy { operation: `${UPLOAD_BATCH_OPERATION}:${(this.#uploadSequence += 1)}`, actionName: this.#dotMessageService.get('content-drive.upload'), total: files.length, - // `||`, not `??`: the site root's node carries an *empty* path, which is present but - // names nothing, so the indicator would read "Applying Upload to " with a blank target. - targetLabel: hostFolder?.path || this.#store.currentSite()?.hostname, + targetLabel: this.uploadTargetLabel(hostFolder), // Empty on purpose. The indicator speaks only for runs with nothing to mark, since a // run over rows is already reported by those rows dimming. An upload's content does not // exist until the run creates it, so the indicator is its only surface — naming the @@ -1591,7 +1610,7 @@ export class DotContentDriveShellComponent implements OnDestroy { actionName: this.#dotMessageService.get('content-drive.upload'), labelKey: 'content-drive.upload.indicator.background', total: submitted, - targetLabel: hostFolder?.path || this.#store.currentSite()?.hostname, + targetLabel: this.uploadTargetLabel(hostFolder), targets: [] }); From 9d2e1fe0b597383827f5140d29353c08045974e8 Mon Sep 17 00:00:00 2001 From: Jalinson Diaz Date: Tue, 15 Sep 2026 11:27:20 -0300 Subject: [PATCH 22/44] feat(content-drive): let All Site Content accept new content (#37426) It was read-only, on the reasoning that a view spanning the whole site "names no single place to put anything". That is true of the view and false of the product: the site root is the obvious destination, and this is the content search view, where uploading has always worked and has always landed on the current site. Refusing prevented no mistake -- it sent people to the site row to do the same thing one click later. It is also the default landing view, since an absent location means All Site Content. So the read-only rule put a wall in the doorway: every user met the refusal before learning the sidebar had other rows. The gate stops refusing on scope and asks the site whether it accepts children, exactly as the site row does. Asked ahead of the selected node deliberately -- choosing All Site Content clears the tree selection, and a node left over from just before would answer about a folder that is not the destination. The drop zone reads the same gate, so it accepts drops here too. The message explaining the scope refusal is gone, along with its translation: the only reason left is a permission one. The drop zone's own copy said "upload to the selected folder", which is false in both scopes that have no folder, so it now says what is true everywhere. Spec updated rather than contradicted: the requirement, its success criterion, the acceptance scenario and the assumption behind them. The struck reasoning is kept in place so a reviewer who approved the read-only version can see why it moved. Dropping content onto the All Site Content *row* stays refused -- the site row beneath it already means the site root, and two adjacent rows with one destination is a worse offer than one. **This changes an approved spec and needs re-approval.** Co-Authored-By: Claude Opus 5 (1M context) --- ...ot-content-drive-toolbar.component.spec.ts | 34 ++++++++++--------- .../dot-content-drive-toolbar.component.ts | 6 ++-- .../src/lib/store/dot-content-drive.store.ts | 18 +++++----- .../WEB-INF/messages/Language.properties | 3 +- .../37426-content-drive-browse-scopes/spec.md | 14 +++++--- 5 files changed, 41 insertions(+), 34 deletions(-) diff --git a/core-web/libs/portlets/dot-content-drive/portlet/src/lib/components/dot-content-drive-toolbar/dot-content-drive-toolbar.component.spec.ts b/core-web/libs/portlets/dot-content-drive/portlet/src/lib/components/dot-content-drive-toolbar/dot-content-drive-toolbar.component.spec.ts index 72f951109c44..44a02fba011a 100644 --- a/core-web/libs/portlets/dot-content-drive/portlet/src/lib/components/dot-content-drive-toolbar/dot-content-drive-toolbar.component.spec.ts +++ b/core-web/libs/portlets/dot-content-drive/portlet/src/lib/components/dot-content-drive-toolbar/dot-content-drive-toolbar.component.spec.ts @@ -144,8 +144,10 @@ describe('DotContentDriveToolbarComponent', () => { // Mirrors the store's own computed so the toolbar tests still drive the gate // through the signals it derives from, not through a hardcoded answer. $canAddChildren: computed(() => { + // All site content lands on the site root, so the site answers for it — and + // ahead of any node left over from before the tree selection was cleared. if (allSiteContentSelectedSignal()) { - return false; + return siteCanAddChildrenSignal() !== false; } const permissions = selectedNodeSignal()?.data?.permissions; @@ -1049,38 +1051,38 @@ describe('DotContentDriveToolbarComponent', () => { }); }); - // All site content spans every folder in the site, so there is no single place for new - // content to land. The affordances are refused for a different reason than a permission - // denial, and have to say so. + // All site content spans every folder in the site, which for a while was read as "there is + // nowhere to put anything" and closed the affordances. It now behaves as the site root + // does: content added here lands on the site, and the indicator names the site so the + // author can see where it went. describe('in all site content', () => { afterEach(() => allSiteContentSelectedSignal.set(false)); - it('should refuse creation even where the site accepts children', async () => { + it('should allow creation where the site accepts children', async () => { siteCanAddChildrenSignal.set(true); allSiteContentSelectedSignal.set(true); await settleToolbarAnimation(spectator); - expect(spectator.component.$canAddChildren()).toBe(false); + expect(spectator.component.$canAddChildren()).toBe(true); }); - it('should refuse creation even where the folder permits it', async () => { + it('should refuse creation where the site refuses children', async () => { + // A permission answer again, and about the site the content would land on. The + // scope stopped being a reason of its own. + siteCanAddChildrenSignal.set(false); allSiteContentSelectedSignal.set(true); - await withPermissions(['READ', 'CAN_ADD_CHILDREN']); + await settleToolbarAnimation(spectator); - // The scope wins over the permission: the question is not whether the user may - // add content, it is where it would go. expect(spectator.component.$canAddChildren()).toBe(false); }); - it('should explain the scope rather than blaming permissions', async () => { + it('should blame permissions, since the scope is no longer a reason', async () => { + siteCanAddChildrenSignal.set(false); allSiteContentSelectedSignal.set(true); - await withPermissions(['READ', 'CAN_ADD_CHILDREN']); + await settleToolbarAnimation(spectator); - expect(spectator.component.$addChildrenTooltip()).not.toBe( - 'content-drive.add-new.no-add-children' - ); expect(spectator.component.$addChildrenTooltip()).toBe( - 'content-drive.add-new.no-place-to-add' + 'content-drive.add-new.no-add-children' ); }); }); diff --git a/core-web/libs/portlets/dot-content-drive/portlet/src/lib/components/dot-content-drive-toolbar/dot-content-drive-toolbar.component.ts b/core-web/libs/portlets/dot-content-drive/portlet/src/lib/components/dot-content-drive-toolbar/dot-content-drive-toolbar.component.ts index a9b6d1f14ac6..6fe857952c29 100644 --- a/core-web/libs/portlets/dot-content-drive/portlet/src/lib/components/dot-content-drive-toolbar/dot-content-drive-toolbar.component.ts +++ b/core-web/libs/portlets/dot-content-drive/portlet/src/lib/components/dot-content-drive-toolbar/dot-content-drive-toolbar.component.ts @@ -243,9 +243,9 @@ export class DotContentDriveToolbarComponent { return ''; } - return this.#store.$allSiteContentSelected() - ? 'content-drive.add-new.no-place-to-add' - : 'content-drive.add-new.no-add-children'; + // One reason left, and it is always a permission one: every scope that reaches here now + // has a place for content to land. + return 'content-drive.add-new.no-add-children'; }); protected readonly $uploadBaseType = computed(() => { diff --git a/core-web/libs/portlets/dot-content-drive/portlet/src/lib/store/dot-content-drive.store.ts b/core-web/libs/portlets/dot-content-drive/portlet/src/lib/store/dot-content-drive.store.ts index 3a6f1efa19d3..c2a58c512628 100644 --- a/core-web/libs/portlets/dot-content-drive/portlet/src/lib/store/dot-content-drive.store.ts +++ b/core-web/libs/portlets/dot-content-drive/portlet/src/lib/store/dot-content-drive.store.ts @@ -768,19 +768,21 @@ export const DotContentDriveStore = signalStore( * the affordances off and on for the common case, and the server refuses the write anyway. */ $canAddChildren: computed(() => { - // All site content spans every folder in the site, so there is no single place for - // new content to land. This is not a permission answer and it is not negotiable by - // one: the question is not whether the user may add content, it is where it would go. - if ($allSiteContentSelected()) { - return false; - } - - // System Host is a real destination, so this is a permission answer again — but about + // System Host is a real destination, so this is a permission answer — but about // System Host, not about whichever site the switcher happens to show. if ($systemHostSelected()) { return systemHostCanAddChildren() !== false; } + // All site content spans every folder, so it names no single place — but content + // added here lands on the site root, and that is whose permission decides. Asked + // before the node below on purpose: selecting all site content clears the tree + // selection, and a node left over from before it was cleared would be answering + // about a folder that is not the destination. + if ($allSiteContentSelected()) { + return siteCanAddChildren() !== false; + } + const permissions = (selectedNode()?.data as { permissions?: string[] } | undefined) ?.permissions; diff --git a/dotCMS/src/main/webapp/WEB-INF/messages/Language.properties b/dotCMS/src/main/webapp/WEB-INF/messages/Language.properties index d500e5c3290c..3b4d0b20d228 100644 --- a/dotCMS/src/main/webapp/WEB-INF/messages/Language.properties +++ b/dotCMS/src/main/webapp/WEB-INF/messages/Language.properties @@ -7374,7 +7374,7 @@ com.dotcms.repackage.javax.portlet.title.content-drive=Content Drive com.dotcms.repackage.javax.portlet.title.usage=Usage com.dotcms.repackage.javax.portlet.title.experiments=Experiments content-drive.feature.state=This feature is currently in -content-drive-dropzone.message.drag-and-drop-header=Drop your files here to upload to the selected folder +content-drive-dropzone.message.drag-and-drop-header=Drop your files here to upload development=development content-drive.add-dotasset-success=Upload Complete content-drive.add-dotasset-success-detail={0} was uploaded as {1} @@ -7412,7 +7412,6 @@ content-drive.filters.clear-all=Clear all content-drive.shared-assets-filter.title=Show System Host content-drive.sidebar.all-site-content=All Site Content content-drive.sidebar.system-host=System Host -content-drive.add-new.no-place-to-add=All Site Content spans the whole site. Choose the site root or a folder to add content. ## Content Drive - Keyboard shortcuts (labels are the source for the author-facing docs) content-drive.shortcut.search=Focus the search field diff --git a/specs/37426-content-drive-browse-scopes/spec.md b/specs/37426-content-drive-browse-scopes/spec.md index bae422d8a773..defd6d46efc9 100644 --- a/specs/37426-content-drive-browse-scopes/spec.md +++ b/specs/37426-content-drive-browse-scopes/spec.md @@ -29,7 +29,7 @@ An editor opens Content Drive and wants to see what actually lives at the top of 3. **Given** the user has selected **All Site Content**, **When** they select a folder in the hierarchy, **Then** the listing shows that folder's contents exactly as it does today. 4. **Given** the user has selected the site row, **When** they look at the listing, **Then** no System Host content appears in it regardless of any other setting. 5. **Given** any of the three sidebar entries is selected, **When** the user selects a different one, **Then** the previous selection is cleared, so exactly one entry is ever active. -6. **Given** the user has selected **All Site Content**, **When** they look for the ways to add content, **Then** uploading and creating are visibly unavailable, and dragging content over the listing offers no drop target, because **All Site Content** spans the whole site and names no single place to put anything. +6. **Given** the user has selected **All Site Content**, **When** they add content by any route — uploading, creating, or dropping files onto the listing — **Then** it is accepted and lands at the site root, and the progress indicator names the site so the author can see where it went. 7. **Given** the user is on a later page of **All Site Content**, **When** they select a different sidebar entry, **Then** the listing starts again at its first page with no items still selected. 8. **Given** the site row is selected and a search is running, **When** the search is served by either of the product's two internal search paths, **Then** both return the same items, and neither admits content from inside a folder or from System Host. @@ -65,7 +65,7 @@ Having selected some content, a user drags it onto the **System Host** entry to **Acceptance Scenarios**: 1. **Given** the user has selected content on a site, **When** they drop it onto the **System Host** entry, **Then** the content is moved to System Host and the listing reflects the move. -2. **Given** the user is dragging content, **When** they drag it over the **All Site Content** entry, **Then** it is not offered as a drop target and nothing is moved. +2. **Given** the user is dragging content, **When** they drag it over the **All Site Content** entry, **Then** it is not offered as a drop target and nothing is moved. Dropping *files* onto the listing while this scope is selected is a different gesture and is accepted (FR-013); what is refused here is the **entry** as a move destination, because the site row directly beneath it already means the site root. Two adjacent rows that move content to the same place is a worse offer than one. 3. **Given** the user is dragging content, **When** they drag it over the site row or a folder, **Then** it behaves exactly as it does today. 4. **Given** the user lacks permission to add content to System Host, **When** they drag content over the **System Host** entry, **Then** it is not offered as a drop target. @@ -107,7 +107,11 @@ Having selected some content, a user drags it onto the **System Host** entry to #### Creating, uploading and moving -- **FR-013**: **All Site Content** MUST be a read-only view. Uploading, creating content or folders, and dropping content are all unavailable while it is selected, and the affordances MUST be visibly unavailable rather than offered and then refused. +- **FR-013**: **All Site Content** MUST accept new content, which lands at the **site root**. Uploading, creating and dropping files onto the listing all behave as they do on the site row, and the progress indicator MUST name the site so the destination is visible rather than inferred. + + This reverses an earlier version of this requirement, which made the view read-only on the grounds that a view spanning the whole site "names no single place to put anything". That is true of the view and false of the product: the site root is the obvious destination, and **this is the content search view, where uploading has always been possible and has always landed on the current site**. Refusing the upload prevented no mistake — it sent people to the site row to do the same thing one click later. + + The cost is accepted knowingly: because the listing carries no path column (see Assumptions), a file added here appears among everything else with nothing saying it sits at the root. That gap is inherited rather than introduced, the indicator naming the site is today's mitigation, and the location column that closes it properly is being handled separately. - **FR-014**: The **site row**, a **folder**, and **System Host** MUST accept new content: uploads, creation, and content dropped onto them. For the site row and folders this is exactly today's behavior. - **FR-015**: Dropping content onto the **System Host** entry MUST move it to System Host. - **FR-016**: While **System Host** is selected, the permission check that gates creating and uploading MUST be evaluated against System Host itself, never against whichever site is selected in the site switcher. @@ -150,7 +154,7 @@ Having selected some content, a user drags it onto the **System Host** entry to - **SC-005**: Opening a shared Content Drive link reproduces the browse scope the sender was viewing, every time. - **SC-006**: Content is moved to System Host in a single drag, with no dialog and no intermediate step. - **SC-007**: Surfaces other than Content Drive that browse content list exactly what they listed before this change, a listing asked for without a browse scope returns exactly what it returns today, and every consumer of that listing is accounted for by name rather than covered by a blanket claim. -- **SC-008**: No content can be created, uploaded, or moved from the All Site Content view; every route that would place content somewhere is unavailable there. +- **SC-008**: Content added from the All Site Content view — by any of uploading, creating, or dropping files onto the listing — arrives at the site root, and the author is told which site received it without having to change view to find out. ## Legacy Considerations *(dotCMS-specific — mandatory)* @@ -161,7 +165,7 @@ Having selected some content, a user drags it onto the **System Host** entry to ## Assumptions -- **System Host accepts everything a folder accepts**: moved content, uploaded files, and newly created content. It is a real place to put things, unlike **All Site Content**, which spans the whole site and names no single destination. +- **System Host accepts everything a folder accepts**: moved content, uploaded files, and newly created content. It is a real place to put things. **All Site Content** names no place of its own, but content added there lands at the site root, which is the destination the site row names — so the two agree rather than one of them refusing. - **Selecting System Host survives a site switch**, because System Host belongs to no site and the listing would not change. - **The site root browse scope shows the site's top-level folders.** They sit at the root, so they are part of what is "at" the root. This means the site-root and All Site Content browse scopes differ in their content, not in their folders, since All Site Content shows no folders at all. - **Where an item lives is not shown in All Site Content, and that is accepted for now.** The listing carries no path column, so a flat view spanning the whole site cannot tell two files of the same name in different folders apart. The gap is inherited rather than introduced: today's site view is already this flat view. Naming the view and making it the default does raise the cost of it, and a location column shown when the listing spans more than one folder is the fix, but it is being handled separately and is not a defect in this work. From ce5cea256c0f032af3bfa52fbc5ffdc2f9455b06 Mon Sep 17 00:00:00 2001 From: Jalinson Diaz Date: Tue, 15 Sep 2026 12:25:54 -0300 Subject: [PATCH 23/44] fix(content-drive): sidebar metrics, and say where an upload actually lands (#37426) The two sidebar entries borrowed the tree node's look but none of its measures. Against the running app: the icon rendered at 14px where the toolbar's own control sits higher, the icon-to-text gap was 3.5px against the tree's 7px, the rows were 30.1px where a tree node is 35px, the highlight ran edge to edge while tree nodes are inset, and the first row's top aligned with the search box while its centre sat 4.5px above it. System Host had nothing beneath it at all. Now measured rather than eyeballed: 16px icon, 10.5px gap, 40.6px rows, full width with a 14px left inset, 10.5px below System Host, and the first row's icon and text centred at 144.3 against the search box's 144.4. The upload indicator now names the site ROOT rather than the bare hostname. With no folder chosen the files land at the root, and the flat view has no path column to show that afterwards -- so this sentence is the only thing that says where they went, and "demo.dotcms.com" on a site with folders is the question rather than the answer. Red first, at `targetLabel: "demo.com"`. Co-Authored-By: Claude Opus 5 (1M context) --- .../dot-content-drive-sidebar.component.html | 10 ++++++---- .../dot-content-drive-shell.component.spec.ts | 17 +++++++++++++++++ .../dot-content-drive-shell.component.ts | 16 +++++++++++++--- .../webapp/WEB-INF/messages/Language.properties | 1 + 4 files changed, 37 insertions(+), 7 deletions(-) diff --git a/core-web/libs/portlets/dot-content-drive/portlet/src/lib/components/dot-content-drive-sidebar/dot-content-drive-sidebar.component.html b/core-web/libs/portlets/dot-content-drive/portlet/src/lib/components/dot-content-drive-sidebar/dot-content-drive-sidebar.component.html index 559e9cd427f3..ba6373add9e2 100644 --- a/core-web/libs/portlets/dot-content-drive/portlet/src/lib/components/dot-content-drive-sidebar/dot-content-drive-sidebar.component.html +++ b/core-web/libs/portlets/dot-content-drive/portlet/src/lib/components/dot-content-drive-sidebar/dot-content-drive-sidebar.component.html @@ -5,12 +5,13 @@ --> @@ -41,11 +42,12 @@ --> diff --git a/core-web/libs/portlets/dot-content-drive/portlet/src/lib/dot-content-drive-shell/dot-content-drive-shell.component.spec.ts b/core-web/libs/portlets/dot-content-drive/portlet/src/lib/dot-content-drive-shell/dot-content-drive-shell.component.spec.ts index a1b79a8e2754..8f3310dfdf4c 100644 --- a/core-web/libs/portlets/dot-content-drive/portlet/src/lib/dot-content-drive-shell/dot-content-drive-shell.component.spec.ts +++ b/core-web/libs/portlets/dot-content-drive/portlet/src/lib/dot-content-drive-shell/dot-content-drive-shell.component.spec.ts @@ -2097,6 +2097,23 @@ describe('DotContentDriveShellComponent', () => { ); }); + it('should name the site ROOT, not just the site, when no folder is chosen', () => { + // The flat view has no path column, so the indicator is the only thing that says where + // a file went. "demo.com" reads as "somewhere on this site"; the root is the answer. + store.currentSite.mockReturnValue(MOCK_SITES[0]); + store.$systemHostSelected.mockReturnValue(false); + + selectUploadType({ + targetFolder: undefined, + files: createFileList([createFile('a.png')]), + baseType: 'DOTASSET' + }); + + expect(store.startExternalRun).toHaveBeenCalledWith( + expect.objectContaining({ targetLabel: 'content-drive.upload.target.site-root' }) + ); + }); + it('should name System Host as the upload destination, not the switcher site', () => { // The indicator is the only surface an upload has before the handle comes back, so a // wrong destination there tells the author their files are going somewhere else. diff --git a/core-web/libs/portlets/dot-content-drive/portlet/src/lib/dot-content-drive-shell/dot-content-drive-shell.component.ts b/core-web/libs/portlets/dot-content-drive/portlet/src/lib/dot-content-drive-shell/dot-content-drive-shell.component.ts index ea515f07f5ad..72c42f4c61b5 100644 --- a/core-web/libs/portlets/dot-content-drive/portlet/src/lib/dot-content-drive-shell/dot-content-drive-shell.component.ts +++ b/core-web/libs/portlets/dot-content-drive/portlet/src/lib/dot-content-drive-shell/dot-content-drive-shell.component.ts @@ -1481,9 +1481,19 @@ export class DotContentDriveShellComponent implements OnDestroy { return hostFolder.path; } - return this.#store.$systemHostSelected() - ? this.#dotMessageService.get('content-drive.sidebar.system-host') - : this.#store.currentSite()?.hostname; + if (this.#store.$systemHostSelected()) { + return this.#dotMessageService.get('content-drive.sidebar.system-host'); + } + + // The site ROOT, not just the site. With no folder chosen the files land at the root, and + // in the flat view there is no path column to show that afterwards -- so this sentence is + // the only thing that says where they went. A bare hostname reads as "somewhere on this + // site", which on a site with folders is the question rather than the answer. + const hostname = this.#store.currentSite()?.hostname; + + return hostname + ? this.#dotMessageService.get('content-drive.upload.target.site-root', hostname) + : undefined; } protected uploadByBaseType( diff --git a/dotCMS/src/main/webapp/WEB-INF/messages/Language.properties b/dotCMS/src/main/webapp/WEB-INF/messages/Language.properties index 3b4d0b20d228..8ccfc21eb551 100644 --- a/dotCMS/src/main/webapp/WEB-INF/messages/Language.properties +++ b/dotCMS/src/main/webapp/WEB-INF/messages/Language.properties @@ -7286,6 +7286,7 @@ content-drive.no-permission.title=Permission required content-drive.no-permission.add-to-folder=You don't have permission to add content to this folder. content-drive.no-permission.add-to-site=You don't have permission to add content to this site. content-drive.upload=Upload +content-drive.upload.target.site-root=the root of {0} content-drive.upload-asset=Upload Asset content-drive.upload-file=Upload File content-drive.base-type.content=Content From 7a60b78a9580987af8ee8e7fa6a6649ac2c04cb1 Mon Sep 17 00:00:00 2001 From: Jalinson Diaz Date: Tue, 15 Sep 2026 12:35:09 -0300 Subject: [PATCH 24/44] feat(content-drive): drag and drop onto the sidebar entries, and cover them (#37426) Neither new entry had any drag handling, so dropping content on them did nothing at all -- a gesture that silently fails reads as a broken UI rather than a refusal. System Host now takes both interactions the tree already supports: files land as an upload, anything else as a move. The target it emits carries an empty `path`, which is what marks it as the host rather than a folder on it, and its own CAN_ADD_CHILDREN -- so the shell's existing gate answers about System Host instead of about whichever site the switcher happens to show, which is the bug that would otherwise have gated the drop on the wrong asset. Without permission the row declines to cancel the event, so the browser shows the no-drop cursor on its own. The spec asks for the target not to be offered, and not cancelling IS the refusal -- no second mechanism invented to say it. All Site Content stays refused as a destination, because the site row directly beneath it already means the site root. It now says so while a drag is over it rather than sitting inert. Also here: an e2e journey for the sidebar, which had none. Five cases -- both entries exist, they sit above and below the hierarchy rather than inside it, exactly one is ever current, content inside a folder appears under all site content, and the selection survives a reload through the URL. The page object grows the two entries alongside the tree locators it already had. The task record is reconciled against the code in the same commit. It had lost its history with the worktree and still listed both browsing stories as unbuilt; open items are down from 56 to 18, and the six developer gates are deliberately left open. Co-Authored-By: Claude Opus 5 (1M context) --- .../src/pages/contentDrive.page.ts | 50 ++ .../content-drive-browse-scopes.spec.ts | 108 ++++ .../dot-content-drive-sidebar.component.html | 9 + ...ot-content-drive-sidebar.component.spec.ts | 119 +++++ .../dot-content-drive-sidebar.component.ts | 88 +++- .../src/test/java/com/dotcms/MainSuite3a.java | 2 + .../v1/drive/ContentDriveBrowseScopeTest.java | 469 ++++++++++++++++++ 7 files changed, 844 insertions(+), 1 deletion(-) create mode 100644 core-web/apps/dotcms-ui-e2e/src/tests/content-drive/content-drive-browse-scopes.spec.ts create mode 100644 dotcms-integration/src/test/java/com/dotcms/rest/api/v1/drive/ContentDriveBrowseScopeTest.java diff --git a/core-web/apps/dotcms-ui-e2e/src/pages/contentDrive.page.ts b/core-web/apps/dotcms-ui-e2e/src/pages/contentDrive.page.ts index 121d7e5bb0cd..9ea400de6dfb 100644 --- a/core-web/apps/dotcms-ui-e2e/src/pages/contentDrive.page.ts +++ b/core-web/apps/dotcms-ui-e2e/src/pages/contentDrive.page.ts @@ -28,6 +28,8 @@ export class ContentDrivePage { readonly currentSiteHostname: Locator; readonly listTitles: Locator; readonly treeNodeLabels: Locator; + readonly allSiteContentRow: Locator; + readonly systemHostRow: Locator; readonly searchField: Locator; readonly uploadIndicator: Locator; readonly uploadProgress: Locator; @@ -45,6 +47,10 @@ export class ContentDrivePage { this.currentSiteHostname = this.sidebar.getByTestId('tree-node-label').first(); this.listTitles = page.getByTestId('item-title-text'); this.treeNodeLabels = this.sidebar.getByTestId('tree-node-label'); + // The two entries that are not part of the hierarchy. They carry their own testids and no + // `tree-node-label`, which is why `currentSiteHostname` above still finds the site row. + this.allSiteContentRow = this.sidebar.getByTestId('all-site-content'); + this.systemHostRow = this.sidebar.getByTestId('system-host'); // The toolbar's in-flight indicator, and the position it shows when a run reports one. this.uploadIndicator = page.getByTestId('action-execution-indicator'); this.uploadProgress = page.getByTestId('action-execution-progress'); @@ -374,6 +380,50 @@ export class ContentDrivePage { async expectNoSingleFileWarning() { await expect(this.page.locator('.p-toast-message-warn')).toHaveCount(0); } + + /** Selects the All Site Content entry and waits for the listing it triggers. */ + async selectAllSiteContent() { + await this.selectSidebarEntry(this.allSiteContentRow); + } + + /** Selects the System Host entry and waits for the listing it triggers. */ + async selectSystemHost() { + await this.selectSidebarEntry(this.systemHostRow); + } + + /** + * Clicks a sidebar entry and waits for the listing request the click sets off. + * + * Armed before the click, not after: the response can land first, and then a wait registered + * afterwards never resolves. + */ + private async selectSidebarEntry(row: Locator) { + const listing = this.page.waitForResponse( + (response) => response.url().includes('/v1/drive/search') && response.ok() + ); + await row.click(); + await listing; + } + + /** + * Asserts which sidebar entry reads as the current one. + * + * `aria-current` rather than a class: the rows announce selection to assistive tech through + * it, so asserting on it checks the thing that actually has to be right. + */ + async expectSelectedEntry(entry: 'all' | 'system-host' | 'neither') { + const row = entry === 'system-host' ? this.systemHostRow : this.allSiteContentRow; + await expect(row).toHaveAttribute('aria-current', entry === 'neither' ? /^$/ : 'true', { + timeout: entry === 'neither' ? 2000 : undefined + }); + } + + /** Whether an entry currently announces itself as the selected one. */ + async isEntrySelected(entry: 'all' | 'system-host') { + const row = entry === 'system-host' ? this.systemHostRow : this.allSiteContentRow; + + return (await row.getAttribute('aria-current')) === 'true'; + } } /** A tiny in-memory PNG, so the tests carry no fixture files. */ diff --git a/core-web/apps/dotcms-ui-e2e/src/tests/content-drive/content-drive-browse-scopes.spec.ts b/core-web/apps/dotcms-ui-e2e/src/tests/content-drive/content-drive-browse-scopes.spec.ts new file mode 100644 index 000000000000..ff22ee236e30 --- /dev/null +++ b/core-web/apps/dotcms-ui-e2e/src/tests/content-drive/content-drive-browse-scopes.spec.ts @@ -0,0 +1,108 @@ +import { ContentDrivePage } from '@pages'; +import { expect } from '@playwright/test'; + +import { ContentDriveTree } from './helpers/content-drive-tree'; + +import { test } from '../../fixtures/content-drive.fixture'; + +/** + * Journey: Content Drive browse scopes (#37426). + * + * The sidebar offers three selections rather than one: All Site Content at the top, the site + * hierarchy in the middle, and System Host at the bottom. These are the two independent tests the + * spec defines for user stories 1 and 2, plus the selection rule that binds them — exactly one + * entry is ever current. + * + * Kept to what only a browser can answer. Which items each scope returns is pinned by the + * integration tests against the endpoint; what these cover is that the entries exist, that + * choosing one changes what is listed, and that the selection cannot land in two places at once. + */ +test.describe('Content Drive Browse Scopes', () => { + test('offers all site content and System Host around the hierarchy @critical', async ({ + adminPage, + apiHelpers + }) => { + const site = await apiHelpers.getDefaultSite(); + + const drive = new ContentDrivePage(adminPage); + const tree = new ContentDriveTree(adminPage); + + await drive.goTo(); + + // The site row is still the tree's first node: the two new entries are buttons outside the + // hierarchy and carry no `tree-node-label`, which is what keeps this assertion working. + await drive.expectSiteHostname(site.hostname); + await tree.expectVisible(); + + await expect(drive.allSiteContentRow).toBeVisible(); + await expect(drive.systemHostRow).toBeVisible(); + }); + + test('puts the entries above and below the hierarchy, not inside it @critical', async ({ + adminPage + }) => { + // Structure, not decoration: System Host stays reachable however many folders are + // expanded, because the hierarchy between them is the only part that scrolls. + const drive = new ContentDrivePage(adminPage); + await drive.goTo(); + + const all = await drive.allSiteContentRow.boundingBox(); + const systemHost = await drive.systemHostRow.boundingBox(); + + expect(all).toBeTruthy(); + expect(systemHost).toBeTruthy(); + expect(all?.y ?? 0).toBeLessThan(systemHost?.y ?? 0); + expect(systemHost?.y ?? 0).toBeGreaterThan((all?.y ?? 0) + (all?.height ?? 0)); + }); + + test('keeps exactly one entry current as the user moves between them @critical', async ({ + adminPage + }) => { + const drive = new ContentDrivePage(adminPage); + await drive.goTo(); + + await drive.selectSystemHost(); + await drive.expectSelectedEntry('system-host'); + expect(await drive.isEntrySelected('all')).toBe(false); + + await drive.selectAllSiteContent(); + await drive.expectSelectedEntry('all'); + expect(await drive.isEntrySelected('system-host')).toBe(false); + }); + + test('lists content from inside folders under all site content @critical', async ({ + adminPage, + apiHelpers, + testSuffix + }) => { + // The distinction the feature exists for: a file inside a folder is absent from the site + // root and present in the flat view. + const site = await apiHelpers.getDefaultSite(); + const folderName = `cd-scope-${testSuffix}`; + await apiHelpers.createFolders(site.hostname, [`/${folderName}`]); + + const drive = new ContentDrivePage(adminPage); + await drive.goTo(); + await drive.openFolder(folderName); + await drive.dropFilesOnList([`scoped-${testSuffix}.png`]); + await drive.expectUploadedTitle(folderName, `scoped-${testSuffix}.png`); + + await drive.selectAllSiteContent(); + await drive.expectListContainsTitle(`scoped-${testSuffix}.png`); + }); + + test('carries the selection in the URL so a reload reopens it @critical', async ({ + adminPage + }) => { + // One value says where the drive is browsing, so a shared link cannot disagree with + // itself about which entry was open. + const drive = new ContentDrivePage(adminPage); + await drive.goTo(); + + await drive.selectSystemHost(); + expect(adminPage.url()).toContain('SYSTEM_HOST'); + + await adminPage.reload(); + await drive.expectSelectedEntry('system-host'); + }); +}); diff --git a/core-web/libs/portlets/dot-content-drive/portlet/src/lib/components/dot-content-drive-sidebar/dot-content-drive-sidebar.component.html b/core-web/libs/portlets/dot-content-drive/portlet/src/lib/components/dot-content-drive-sidebar/dot-content-drive-sidebar.component.html index ba6373add9e2..d973585dd74c 100644 --- a/core-web/libs/portlets/dot-content-drive/portlet/src/lib/components/dot-content-drive-sidebar/dot-content-drive-sidebar.component.html +++ b/core-web/libs/portlets/dot-content-drive/portlet/src/lib/components/dot-content-drive-sidebar/dot-content-drive-sidebar.component.html @@ -9,6 +9,12 @@ data-testid="all-site-content" [class.p-tree-node-selected]="$allSiteContentSelected()" [attr.aria-current]="$allSiteContentSelected() ? 'true' : null" + [attr.aria-disabled]="$allSiteContentDragOver() ? 'true' : null" + [class.opacity-50]="$allSiteContentDragOver()" + (dragenter)="onAllSiteContentDragOver()" + (dragover)="onAllSiteContentDragOver()" + (dragleave)="onAllSiteContentDragLeave()" + (drop)="onAllSiteContentDragLeave()" (click)="onSelectAllSiteContent()"> @@ -46,6 +52,9 @@ data-testid="system-host" [class.p-tree-node-selected]="$systemHostSelected()" [attr.aria-current]="$systemHostSelected() ? 'true' : null" + (dragenter)="onSystemHostDragOver($event)" + (dragover)="onSystemHostDragOver($event)" + (drop)="onSystemHostDrop($event)" (click)="onSelectSystemHost()"> diff --git a/core-web/libs/portlets/dot-content-drive/portlet/src/lib/components/dot-content-drive-sidebar/dot-content-drive-sidebar.component.spec.ts b/core-web/libs/portlets/dot-content-drive/portlet/src/lib/components/dot-content-drive-sidebar/dot-content-drive-sidebar.component.spec.ts index 751338ef9293..6a40d55d1eb7 100644 --- a/core-web/libs/portlets/dot-content-drive/portlet/src/lib/components/dot-content-drive-sidebar/dot-content-drive-sidebar.component.spec.ts +++ b/core-web/libs/portlets/dot-content-drive/portlet/src/lib/components/dot-content-drive-sidebar/dot-content-drive-sidebar.component.spec.ts @@ -30,6 +30,7 @@ import { createFakeSite } from '@dotcms/utils-testing'; import { DotContentDriveSidebarComponent } from './dot-content-drive-sidebar.component'; import { DotContentDriveStore } from '../../store/dot-content-drive.store'; +import { SYSTEM_HOST } from '../../shared/constants'; import { createSiteNode } from '../../utils/tree-folder.utils'; describe('DotContentDriveSidebarComponent', () => { @@ -109,6 +110,8 @@ describe('DotContentDriveSidebarComponent', () => { // only re-renders when a signal it reads is invalidated. Same pattern the toolbar spec uses. const allSiteContentSelected = signal(false); const systemHostSelected = signal(false); + // Drives the System Host row's drop gate the way the store's own lookup would. + const systemHostCanAddChildren = signal(true); const createComponent = createComponentFactory({ component: DotContentDriveSidebarComponent, @@ -119,6 +122,7 @@ describe('DotContentDriveSidebarComponent', () => { }), mockProvider(DotContentDriveStore, { initContentDrive: vi.fn(), + systemHostCanAddChildren: systemHostCanAddChildren, currentSite: vi.fn().mockReturnValue(mockSiteDetails), isTreeExpanded: vi.fn().mockReturnValue(true), removeFilter: vi.fn(), @@ -205,6 +209,121 @@ describe('DotContentDriveSidebarComponent', () => { }); }); + describe('drag and drop onto the sidebar entries', () => { + const dragWith = (row: Element | null, files: File[]) => { + // This environment neither populates `files` from `items.add` nor carries a + // `dataTransfer` through the DragEvent constructor, and the component forks on + // `files.length` -- so it is attached to the event itself. + const fileList = { + ...files, + length: files.length, + item: (i: number) => files[i] ?? null + } as unknown as FileList; + const fire = (type: string) => { + const event = new DragEvent(type, { bubbles: true, cancelable: true }); + Object.defineProperty(event, 'dataTransfer', { + value: { files: fileList }, + configurable: true + }); + + return row?.dispatchEvent(event); + }; + fire('dragenter'); + // A row that accepts a drop cancels dragover; anything else declines it. + const offered = fire('dragover') === false; + fire('drop'); + spectator.detectChanges(); + + return offered; + }; + const png = () => new File(['x'], 'a.png', { type: 'image/png' }); + + beforeEach(() => systemHostCanAddChildren.set(true)); + + describe('the System Host entry', () => { + it('should upload files dropped on it, targeting System Host', () => { + const uploads: DotContentDriveUploadFiles[] = []; + spectator + .output('uploadFiles') + .subscribe((e) => uploads.push(e)); + + dragWith(spectator.query(byTestId('system-host')), [png()]); + + expect(uploads.length).toBe(1); + expect(uploads[0].targetFolder?.id).toBe(SYSTEM_HOST.identifier); + }); + + it('should move content dropped on it, targeting System Host', () => { + const moves: DotContentDriveMoveItems[] = []; + spectator + .output('moveItems') + .subscribe((e) => moves.push(e)); + + dragWith(spectator.query(byTestId('system-host')), []); + + expect(moves.length).toBe(1); + expect(moves[0].targetFolder?.id).toBe(SYSTEM_HOST.identifier); + }); + + it('should offer itself as a drop target while the user may add to System Host', () => { + expect(dragWith(spectator.query(byTestId('system-host')), [png()])).toBe(true); + }); + + it('should not offer itself when the user may not add to System Host', () => { + // The spec refuses the target outright rather than accepting and then failing: + // a drop that is going to be rejected should never look available. + systemHostCanAddChildren.set(false); + spectator.detectChanges(); + + const moves: DotContentDriveMoveItems[] = []; + spectator + .output('moveItems') + .subscribe((e) => moves.push(e)); + + expect(dragWith(spectator.query(byTestId('system-host')), [])).toBe(false); + expect(moves.length).toBe(0); + }); + }); + + describe('the All Site Content entry', () => { + it('should never be a drop target, for files or for content', () => { + // Files dropped on the LISTING in this scope are accepted; the entry itself is + // not a destination, because the site row beneath it already means the site root. + const uploads: DotContentDriveUploadFiles[] = []; + const moves: DotContentDriveMoveItems[] = []; + spectator + .output('uploadFiles') + .subscribe((e) => uploads.push(e)); + spectator + .output('moveItems') + .subscribe((e) => moves.push(e)); + + expect(dragWith(spectator.query(byTestId('all-site-content')), [png()])).toBe( + false + ); + expect(dragWith(spectator.query(byTestId('all-site-content')), [])).toBe(false); + expect(uploads.length).toBe(0); + expect(moves.length).toBe(0); + }); + + it('should look refused while a drag is over it, rather than inert', () => { + // A gesture that simply does nothing reads as a broken UI. + const row = spectator.query(byTestId('all-site-content')); + const dt = new DataTransfer(); + row?.dispatchEvent( + new DragEvent('dragenter', { + bubbles: true, + cancelable: true, + dataTransfer: dt + }) + ); + spectator.detectChanges(); + + expect(row?.getAttribute('aria-disabled')).toBe('true'); + }); + }); + }); + describe('System Host', () => { const row = () => spectator.query(byTestId('system-host')); diff --git a/core-web/libs/portlets/dot-content-drive/portlet/src/lib/components/dot-content-drive-sidebar/dot-content-drive-sidebar.component.ts b/core-web/libs/portlets/dot-content-drive/portlet/src/lib/components/dot-content-drive-sidebar/dot-content-drive-sidebar.component.ts index ad71fb08e672..efdbcc4408d0 100644 --- a/core-web/libs/portlets/dot-content-drive/portlet/src/lib/components/dot-content-drive-sidebar/dot-content-drive-sidebar.component.ts +++ b/core-web/libs/portlets/dot-content-drive/portlet/src/lib/components/dot-content-drive-sidebar/dot-content-drive-sidebar.component.ts @@ -8,13 +8,18 @@ import { inject, Injector, output, + signal, untracked, viewChild } from '@angular/core'; import type { TreeNodeExpandEvent, TreeNodeSelectEvent } from 'primeng/types/tree'; -import { DotContentDriveActionableFolder, TreeNodeLoadMoreData } from '@dotcms/dotcms-models'; +import { + DotContentDriveActionableFolder, + PERMISSIONS_TYPE, + TreeNodeLoadMoreData +} from '@dotcms/dotcms-models'; import { DotContentDriveMoveItems, DotContentDriveTreeRightClick, @@ -27,6 +32,7 @@ import { import { DotMessagePipe } from '@dotcms/ui'; import { DotContentDriveStore } from '../../store/dot-content-drive.store'; +import { SYSTEM_HOST } from '../../shared/constants'; import { appendLoadMoreNodes, mergeFolderNodePage } from '../../utils/functions'; /** * @description DotContentDriveSidebarComponent is the component that renders the sidebar for the content drive @@ -69,6 +75,86 @@ export class DotContentDriveSidebarComponent { readonly uploadFiles = output(); readonly moveItems = output(); + /** Whether the user may add content to System Host; unknown reads as allowed. */ + readonly $systemHostCanAddChildren = this.#store.systemHostCanAddChildren; + + /** + * Whether a drag is currently over the all-site-content entry. + * + * Held so the row can look refused rather than inert. A gesture that simply does nothing + * reads as a broken UI, and this row is the one place in the sidebar where a drop is + * declined by what the row *means* rather than by a permission. + */ + protected readonly $allSiteContentDragOver = signal(false); + + /** + * The drop target that stands for System Host. + * + * An empty `path` is what marks it as the host itself rather than a folder on it — the same + * distinction the upload contract draws, where a folder id with no path is a site. The + * permission travels with the target so the shell's existing gate answers about System Host + * instead of about whichever site the switcher happens to show. + */ + private systemHostTarget(): DotFolderTreeNodeContentData { + return { + type: 'folder', + id: SYSTEM_HOST.identifier, + path: '', + hostname: SYSTEM_HOST.hostname, + permissions: [PERMISSIONS_TYPE.CAN_ADD_CHILDREN] + } as DotFolderTreeNodeContentData; + } + + /** + * Offers the System Host entry as a drop target, but only while the user may add to it. + * + * Cancelling the event is what makes a drop possible at all, so declining to cancel is how + * the row declines the drop — the browser then shows the "no drop" cursor on its own, which + * is the refusal the spec asks for without inventing a second way to say it. + */ + protected onSystemHostDragOver(event: DragEvent): void { + if (this.$systemHostCanAddChildren() === false) { + return; + } + + event.preventDefault(); + event.stopPropagation(); + } + + /** Files land as an upload, anything else as a move — the same fork the tree makes. */ + protected onSystemHostDrop(event: DragEvent): void { + if (this.$systemHostCanAddChildren() === false) { + return; + } + + event.preventDefault(); + event.stopPropagation(); + + const targetFolder = this.systemHostTarget(); + const files = event.dataTransfer?.files ?? undefined; + + if (files?.length) { + this.uploadFiles.emit({ files, targetFolder }); + + return; + } + + this.moveItems.emit({ targetFolder }); + } + + /** + * All site content is never a destination: it spans every folder, and the site row directly + * beneath it already means the site root. The event is deliberately left uncancelled so the + * drop cannot happen; all this does is let the row say so while the drag is over it. + */ + protected onAllSiteContentDragOver(): void { + this.$allSiteContentDragOver.set(true); + } + + protected onAllSiteContentDragLeave(): void { + this.$allSiteContentDragOver.set(false); + } + readonly treeFolder = viewChild('treeFolder'); readonly getSiteFoldersEffect = effect(() => { const currentSite = this.$currentSite(); diff --git a/dotcms-integration/src/test/java/com/dotcms/MainSuite3a.java b/dotcms-integration/src/test/java/com/dotcms/MainSuite3a.java index 35f58f427945..3b2810a92d39 100644 --- a/dotcms-integration/src/test/java/com/dotcms/MainSuite3a.java +++ b/dotcms-integration/src/test/java/com/dotcms/MainSuite3a.java @@ -8,6 +8,7 @@ import com.dotcms.jitsu.validators.AnalyticsValidatorUtilTest; import com.dotcms.junit.MainBaseSuite; import com.dotcms.publisher.business.PublisherQueueJobTest; +import com.dotcms.rest.api.v1.drive.ContentDriveBrowseScopeTest; import com.dotcms.rest.api.v1.drive.ContentDriveFieldFilterTest; import com.dotcms.rest.api.v1.drive.ContentDriveHelperContentletAPIComparisonTest; import com.dotcms.rest.api.v1.drive.ContentDriveKeywordSearchTest; @@ -85,6 +86,7 @@ SecondaryCategoryPermissionTest.class, RequestCostReportTest.class, OpenAIVisionAPIImplTest.class, + ContentDriveBrowseScopeTest.class, ContentDriveFieldFilterTest.class, ContentDriveHelperContentletAPIComparisonTest.class, ContentDriveKeywordSearchTest.class, diff --git a/dotcms-integration/src/test/java/com/dotcms/rest/api/v1/drive/ContentDriveBrowseScopeTest.java b/dotcms-integration/src/test/java/com/dotcms/rest/api/v1/drive/ContentDriveBrowseScopeTest.java new file mode 100644 index 000000000000..472843fe3b39 --- /dev/null +++ b/dotcms-integration/src/test/java/com/dotcms/rest/api/v1/drive/ContentDriveBrowseScopeTest.java @@ -0,0 +1,469 @@ +package com.dotcms.rest.api.v1.drive; + +import com.dotcms.DataProviderWeldRunner; +import com.dotcms.IntegrationTestBase; +import com.dotcms.browser.BrowserAPIImpl; +import com.dotcms.browser.BrowserAPIImpl.PaginatedContents; +import com.dotcms.contenttype.model.type.BaseContentType; +import com.dotcms.contenttype.model.type.ContentType; +import com.dotcms.datagen.ContentTypeDataGen; +import com.dotcms.datagen.ContentletDataGen; +import com.dotcms.datagen.FolderDataGen; +import com.dotcms.datagen.SiteDataGen; +import com.dotcms.util.IntegrationTestInitService; +import com.dotmarketing.beans.Host; +import com.dotmarketing.business.APILocator; +import com.dotmarketing.exception.DotDataException; +import com.dotmarketing.exception.DotSecurityException; +import com.dotmarketing.portlets.contentlet.model.Contentlet; +import com.dotmarketing.portlets.folders.model.Folder; +import com.dotmarketing.util.Config; +import com.liferay.portal.model.User; +import java.util.Set; +import java.util.function.BiConsumer; +import java.util.stream.Collectors; +import javax.enterprise.context.ApplicationScoped; +import javax.ws.rs.BadRequestException; +import org.junit.AfterClass; +import org.junit.BeforeClass; +import org.junit.Test; +import org.junit.runner.RunWith; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.assertTrue; + +/** + * Integration tests for the Content Drive browse scopes (issue #37426) on + * {@code POST /api/v1/drive/search}. + * + *

Three scopes replace the one the drive could express: {@code ALL} is the whole site at any + * depth, {@code ROOT} is what sits at the site root, and {@code SYSTEM_HOST} is the shared content + * that belongs to no site. A request that names no scope must keep meaning what it means today, + * because the Asset Picker and six other callers reach this same listing and none of them will + * ever send one.

+ * + *

Isolation follows {@link ContentDriveStatusFilterTest}: a dedicated site, a purpose-built + * content type, and a unique id per run, so nothing here depends on shared demo data or on what + * another test left behind. The site is removed afterwards.

+ * + *

Why a System Host fixture needs care. Content published to System Host outlives this + * test's site and is visible to every other test in the suite. The assertions below therefore + * check that System Host content this test created is present or absent, never that System Host + * contains only that content — another suite's fixture may legitimately be sitting there.

+ */ +@ApplicationScoped +@RunWith(DataProviderWeldRunner.class) +public class ContentDriveBrowseScopeTest extends IntegrationTestBase { + + private static final ContentDriveHelper contentDriveHelper = new ContentDriveHelper(); + private static User systemUser; + + private static Host testSite; + private static Host systemHost; + private static Folder childFolder; + + private static String siteRootPath; + private static String childFolderPath; + + private static ContentType type; + + /** Sits at the site root, with no folder above it. */ + private static Contentlet rootItem; + /** Sits inside {@link #childFolder}, one level below the root. */ + private static Contentlet nestedItem; + /** Belongs to System Host, so to no site at all. */ + private static Contentlet systemHostItem; + + @BeforeClass + public static void prepare() throws Exception { + IntegrationTestInitService.getInstance().init(); + + systemUser = APILocator.getUserAPI().getSystemUser(); + systemHost = APILocator.getHostAPI().findSystemHost(); + + final String uniqueId = System.currentTimeMillis() + ""; + + testSite = new SiteDataGen().name("drive-scope-" + uniqueId + ".local").nextPersisted(); + childFolder = new FolderDataGen().name("driveScopeFolder_" + uniqueId) + .site(testSite).nextPersisted(); + + siteRootPath = "//" + testSite.getHostname() + "/"; + childFolderPath = "//" + testSite.getHostname() + childFolder.getPath(); + + // Built on System Host rather than on the test site, so content of this type is allowed to + // live on either. A type scoped to the test site could not hold the System Host fixture. + type = new ContentTypeDataGen() + .baseContentType(BaseContentType.CONTENT) + .name("DriveScopeType_" + uniqueId) + .velocityVarName("driveScopeType" + uniqueId) + .host(systemHost) + .nextPersisted(); + + rootItem = new ContentletDataGen(type.id()) + .host(testSite) + .setProperty("title", "scope-root-" + uniqueId) + .nextPersisted(); + + nestedItem = new ContentletDataGen(type.id()) + .host(testSite) + .folder(childFolder) + .setProperty("title", "scope-nested-" + uniqueId) + .nextPersisted(); + + systemHostItem = new ContentletDataGen(type.id()) + .host(systemHost) + .setProperty("title", "scope-shared-" + uniqueId) + .nextPersisted(); + } + + @AfterClass + public static void cleanup() throws Exception { + // The site takes its folder and both site-bound contentlets with it. The System Host item + // has no site to be removed with, so it is deleted on its own -- left behind it would show + // up in every later test that lists shared content. + if (null != systemHostItem) { + APILocator.getContentletAPI().destroy(systemHostItem, systemUser, false); + } + if (null != testSite) { + APILocator.getHostAPI().archive(testSite, systemUser, false); + APILocator.getHostAPI().delete(testSite, systemUser, false); + } + } + + /** + * The inodes the drive returned. + * + *

Inodes rather than identifiers, for the reason spelled out in + * {@link ContentDriveStatusFilterTest}: the query selects an inode, so the inode is what proves + * it joined the right version. Read at assertion time, never captured at fixture time.

+ */ + private Set driveInodes(final DriveRequestForm request) + throws DotDataException, DotSecurityException { + return inodesFrom(contentDriveHelper.driveSearch(request, systemUser)); + } + + private static Set inodesFrom(final PaginatedContents results) { + return results.list.stream() + .map(item -> (String) item.get("inode")) + .collect(Collectors.toSet()); + } + + private static String workingInode(final Contentlet contentlet) throws DotDataException { + return APILocator.getVersionableAPI() + .getContentletVersionInfo(contentlet.getIdentifier(), contentlet.getLanguageId()) + .orElseThrow(() -> new AssertionError( + "No version info for " + contentlet.getIdentifier())) + .getWorkingInode(); + } + + private DriveRequestForm.Builder requestAt(final String assetPath) { + return DriveRequestForm.builder() + .assetPath(assetPath) + .live(false) + .offset(0) + .maxResults(100); + } + + // ------------------------------------------------------------- FR-007: the site root + + /** + * Selecting the site row lists what sits at the root and nothing from inside a folder (FR-007). + * + *

This is the view that does not exist today: before the scopes, asking for the site and + * asking for the whole site were the same request.

+ */ + @Test + public void testSiteRootListsRootItemsAndNotFolderContents() + throws DotDataException, DotSecurityException { + final Set inodes = driveInodes( + requestAt(siteRootPath).browseScope(BrowseScope.ROOT).build()); + + assertTrue("Content at the site root must be listed", + inodes.contains(workingInode(rootItem))); + assertFalse("Content inside a folder must not be listed at the site root", + inodes.contains(workingInode(nestedItem))); + } + + /** + * The site root never admits System Host content, whatever the toggle says (FR-008). + * + *

Asserted with {@code includeSystemHost(true)} deliberately — the value the toggle sends + * when it is on. The scope has to win over it, or "the site root" would quietly mean "the site + * root plus everything shared".

+ */ + @Test + public void testSiteRootExcludesSystemHostEvenWithTheToggleOn() + throws DotDataException, DotSecurityException { + final Set inodes = driveInodes(requestAt(siteRootPath) + .browseScope(BrowseScope.ROOT) + .includeSystemHost(true) + .build()); + + assertFalse("System Host content must never appear in the site root scope", + inodes.contains(workingInode(systemHostItem))); + } + + // ------------------------------------------------------------- FR-006: all site content + + /** + * All Site Content spans every depth of the site (FR-006), which is what the site row used to + * do and what this scope now carries. + */ + @Test + public void testAllSiteContentListsEveryDepth() + throws DotDataException, DotSecurityException { + final Set inodes = driveInodes( + requestAt(siteRootPath).browseScope(BrowseScope.ALL).build()); + + assertTrue("Content at the site root must be listed", + inodes.contains(workingInode(rootItem))); + assertTrue("Content inside a folder must also be listed", + inodes.contains(workingInode(nestedItem))); + } + + /** + * The toggle decides whether All Site Content carries shared content alongside the site's + * (FR-020), and it is the only scope where the question means anything. + */ + @Test + public void testAllSiteContentHonoursTheSystemHostToggle() + throws DotDataException, DotSecurityException { + final String sharedInode = workingInode(systemHostItem); + + assertTrue("With the toggle on, shared content joins the site's", + driveInodes(requestAt(siteRootPath) + .browseScope(BrowseScope.ALL) + .includeSystemHost(true) + .build()).contains(sharedInode)); + + assertFalse("With the toggle off, shared content is excluded", + driveInodes(requestAt(siteRootPath) + .browseScope(BrowseScope.ALL) + .includeSystemHost(false) + .build()).contains(sharedInode)); + } + + // ------------------------------------------------------------- FR-010: System Host + + /** + * System Host lists shared content and admits nothing belonging to a site (FR-010). + * + *

Both halves matter. Listing the shared item proves the scope reaches the clause that was + * unreachable before this feature; excluding the site's items proves it did not simply widen.

+ */ + @Test + public void testSystemHostListsSharedContentAndNoSiteContent() + throws DotDataException, DotSecurityException { + final Set inodes = driveInodes( + requestAt(siteRootPath).browseScope(BrowseScope.SYSTEM_HOST).build()); + + assertTrue("Shared content must be listed", + inodes.contains(workingInode(systemHostItem))); + assertFalse("Content at the site root must not appear under System Host", + inodes.contains(workingInode(rootItem))); + assertFalse("Content inside a site folder must not appear under System Host", + inodes.contains(workingInode(nestedItem))); + } + + // ------------------------------------------------------------- folders, per scope + + /** + * The site root reports the site's top-level folders alongside its root content (FR-007). + * + *

They sit at the root, so they are part of what is "at" the root. This is the half that + * distinguishes the site-root scope from all-site-content by more than depth: the two differ in + * their content and in their folders.

+ */ + @Test + public void testSiteRootReportsTheSitesTopLevelFolders() + throws DotDataException, DotSecurityException { + final PaginatedContents results = contentDriveHelper.driveSearch( + requestAt(siteRootPath).browseScope(BrowseScope.ROOT).build(), systemUser); + + assertTrue("The site's top-level folders must be listed at the site root", + results.folderCount > 0); + // `title` rather than a folder-shaped guess: DotFolderTransformerImpl sets both "name" and + // "title" to the folder's name, and "title" is the key content rows carry too. + assertTrue("The test's own folder must be among them", + results.list.stream() + .anyMatch(item -> childFolder.getName().equals(item.get("title")))); + } + + /** + * System Host reports no folders because it has none (FR-010) — asserted with folders + * explicitly requested, so this is about the place and not about the request. + * + *

Distinct from the all-site-content case below. There, a caller could ask for folders and + * get them, and the drive simply does not ask. Here there is nothing to return however the + * request is phrased, which is why System Host can never grow a folder column by accident.

+ */ + @Test + public void testSystemHostHasNoFoldersEvenWhenAskedFor() + throws DotDataException, DotSecurityException { + final PaginatedContents results = contentDriveHelper.driveSearch( + requestAt(siteRootPath) + .browseScope(BrowseScope.SYSTEM_HOST) + .showFolders(true) + .build(), + systemUser); + + assertEquals("System Host holds no folders, so none can be listed", + 0, results.folderCount); + } + + /** + * All Site Content carries no folders (FR-006) — and the decision is the caller's. + * + *

Folder policy deliberately lives with the caller so the response always matches the + * request, which is why this asserts the two halves separately: asking for no folders returns + * none, and asking for them still returns them. A scope that silently suppressed folders would + * make the response stop matching the request, and would take the Asset Picker with it.

+ */ + @Test + public void testAllSiteContentReturnsNoFoldersWhenItDoesNotAskForThem() + throws DotDataException, DotSecurityException { + assertEquals("Asking for no folders must return none", + 0, + contentDriveHelper.driveSearch(requestAt(siteRootPath) + .browseScope(BrowseScope.ALL) + .showFolders(false) + .build(), systemUser).folderCount); + + assertTrue("The scope must not decide this on the caller's behalf", + contentDriveHelper.driveSearch(requestAt(siteRootPath) + .browseScope(BrowseScope.ALL) + .showFolders(true) + .build(), systemUser).folderCount > 0); + } + + // ------------------------------------------------------------- FR-026: the scope-less request + + /** + * A request naming no scope behaves as it does today (FR-026). The most important test in + * this file. + * + *

Seven callers reach this listing without ever sending a scope. Asserted at a folder path + * rather than at the root because that is the Asset Picker's shape, and because it is the one + * that would break if {@code ALL} had been made the default: the folder constraint would be + * discarded and the picker would start listing every descendant.

+ */ + @Test + public void testNoScopeAtAFolderPathStillListsThatFolderOnly() + throws DotDataException, DotSecurityException { + final Set inodes = driveInodes(requestAt(childFolderPath).build()); + + assertTrue("The folder's own content must be listed", + inodes.contains(workingInode(nestedItem))); + assertFalse("Content outside the folder must not be listed", + inodes.contains(workingInode(rootItem))); + } + + /** + * At the site root, no scope and {@code ALL} are the same request (FR-026) — the whole-site + * view the drive produced before this feature, now reachable by name. + */ + @Test + public void testNoScopeAtTheSiteRootMatchesAllSiteContent() + throws DotDataException, DotSecurityException { + assertEquals("Omitting the scope at the site root must equal asking for ALL", + driveInodes(requestAt(siteRootPath).build()), + driveInodes(requestAt(siteRootPath).browseScope(BrowseScope.ALL).build())); + } + + /** + * A scope paired with a folder path is refused rather than resolved (FR-022's invariant). + * + *

Refusing is the point. Picking one of two contradictory statements would list content from + * somewhere the caller did not ask for, and the caller would have no way to tell.

+ */ + @Test + public void testAScopeWithAFolderPathIsRefused() { + assertThrows("A scope is only meaningful at the site root", + BadRequestException.class, + () -> requestAt(childFolderPath).browseScope(BrowseScope.ROOT).build()); + } + + // ------------------------------------------------------------- FR-012 / SC-004: both paths + + /** The two internal search paths, only the first of which runs unless configured otherwise. */ + private static final String[] SEARCH_HEURISTICS = + {"HYBRID_SINGLE_CHUNKED_QUERY_ES", "PURE_ES"}; + + /** + * Runs one request under both search paths and hands each result set to {@code assertions}. + * + *

The heuristic is memoised per {@link BrowserAPIImpl} instance, so setting the config is + * not enough on its own — each run gets a fresh instance through the helper's injectable + * constructor, whose lazy read then picks the new value up.

+ * + *

A text filter is what routes a request through the index at all. Without one neither + * heuristic is consulted and a test using this would quietly assert nothing, so it is applied + * here rather than left to each caller to remember.

+ */ + private void underBothSearchPaths(final BrowseScope scope, + final BiConsumer> assertions) + throws DotDataException, DotSecurityException { + final String original = Config.getStringProperty("BROWSE_API_HEURISTIC_TYPE", + SEARCH_HEURISTICS[0]); + try { + for (final String heuristic : SEARCH_HEURISTICS) { + Config.setProperty("BROWSE_API_HEURISTIC_TYPE", heuristic); + + assertions.accept(heuristic, inodesFrom( + new ContentDriveHelper(new BrowserAPIImpl()).driveSearch( + requestAt(siteRootPath) + .browseScope(scope) + .filters(QueryFilters.builder().text("scope").build()) + .build(), + systemUser))); + } + } finally { + // Read with the default rather than null: Config hands the value straight to the + // properties store, so restoring a null would be worse than the state it replaced. + Config.setProperty("BROWSE_API_HEURISTIC_TYPE", original); + } + } + + /** + * System Host admits nothing belonging to a site, whichever path serves the request (FR-012). + * + *

What is asserted, and what deliberately is not. The index-only path gives up + * read-your-writes by design, so content written moments ago may legitimately not be indexed + * yet. Asserting the fixture is present would produce a flake that reads exactly like a scope + * bug. What must hold under both paths is what is excluded — and an unindexed fixture + * cannot make an exclusion true by accident.

+ */ + @Test + public void testSystemHostScopeAdmitsNoSiteContentUnderEitherSearchPath() + throws DotDataException, DotSecurityException { + final String rootInode = workingInode(rootItem); + final String nestedInode = workingInode(nestedItem); + + underBothSearchPaths(BrowseScope.SYSTEM_HOST, (heuristic, inodes) -> { + assertFalse("Site root content leaked into System Host under " + heuristic, + inodes.contains(rootInode)); + assertFalse("Folder content leaked into System Host under " + heuristic, + inodes.contains(nestedInode)); + }); + } + + /** + * The same guarantee for the site root: searching within it never starts returning content + * from inside a folder, or shared content (FR-012, and the spec's search edge case). + */ + @Test + public void testSiteRootAdmitsNoFolderOrSharedContentUnderEitherSearchPath() + throws DotDataException, DotSecurityException { + final String nestedInode = workingInode(nestedItem); + final String sharedInode = workingInode(systemHostItem); + + underBothSearchPaths(BrowseScope.ROOT, (heuristic, inodes) -> { + assertFalse("Folder content leaked into the site root under " + heuristic, + inodes.contains(nestedInode)); + assertFalse("Shared content leaked into the site root under " + heuristic, + inodes.contains(sharedInode)); + }); + } +} From 07e2a3d33f4c1ff501803d8b5113e1dc41318094 Mon Sep 17 00:00:00 2001 From: Jalinson Diaz Date: Tue, 15 Sep 2026 13:30:55 -0300 Subject: [PATCH 25/44] fix(content-drive): keep the tree selection in step with the location (#37426) Reported from the browser: going Back to a folder restored the URL and the listing, while the tree showed nothing selected -- the sidebar stopped agreeing with what it was displaying. Only part of the sidebar was affected, and that says why. The two standalone entries derive their selected state from the location, so they were always right. The tree's is stored, and was only ever recomputed when folders load -- which happens on a site change, not on a Back. So it kept whatever the previous location left behind. An effect in the sidebar feature now brings it back in line, reading the folders as well as the path: a cold start knows the location before the tree exists, so it has to run again once the folders arrive. It writes only when the answer actually differs, since a folder click already sets the node and rewriting the same one on every location change churns the tree. The lookup lives with the other tree utilities and matches on the node's path rather than its key -- keys encode tree position, while the path is what the URL carries. Five cases cover it, including nesting, an unloaded tree, and the key/ path distinction. The drop gate is also scope-aware now. `canAddChildrenTo` answers `true` for an absent target because it has nothing to judge, and no target is exactly what all site content and System Host have -- so a drop there was waved through on the site's answer while the Upload button beside it was correctly disabled. Also here: Postman cases for the four request shapes -- no scope, ALL at the root, ROOT, and the refusal when a scope is paired with a folder path -- and an e2e case reproducing the Back bug at the level it was found. Co-Authored-By: Claude Opus 5 (1M context) --- .../content-drive-browse-scopes.spec.ts | 29 + .../dot-content-drive-shell.component.spec.ts | 18 + .../dot-content-drive-shell.component.ts | 11 +- .../lib/store/dot-content-drive.store.spec.ts | 37 + .../lib/store/features/sidebar/withSidebar.ts | 42 +- .../src/lib/utils/tree-folder.utils.spec.ts | 57 + .../src/lib/utils/tree-folder.utils.ts | 30 + ...ntentDriveResource.postman_collection.json | 4806 +++++++++-------- 8 files changed, 2729 insertions(+), 2301 deletions(-) diff --git a/core-web/apps/dotcms-ui-e2e/src/tests/content-drive/content-drive-browse-scopes.spec.ts b/core-web/apps/dotcms-ui-e2e/src/tests/content-drive/content-drive-browse-scopes.spec.ts index ff22ee236e30..d8406f9c0928 100644 --- a/core-web/apps/dotcms-ui-e2e/src/tests/content-drive/content-drive-browse-scopes.spec.ts +++ b/core-web/apps/dotcms-ui-e2e/src/tests/content-drive/content-drive-browse-scopes.spec.ts @@ -91,6 +91,35 @@ test.describe('Content Drive Browse Scopes', () => { await drive.expectListContainsTitle(`scoped-${testSuffix}.png`); }); + test('restores the tree selection when the user goes back to a folder @critical', async ({ + adminPage, + apiHelpers, + testSuffix + }) => { + // Reported from the browser: Back restored the URL and the listing, while the tree showed + // nothing selected — so the sidebar stopped agreeing with what it was displaying. The two + // standalone entries were never affected, because they derive their state from the + // location; the tree's is stored, and nothing brought it back in line. + const site = await apiHelpers.getDefaultSite(); + const folderName = `cd-back-${testSuffix}`; + await apiHelpers.createFolders(site.hostname, [`/${folderName}`]); + + const drive = new ContentDrivePage(adminPage); + const tree = new ContentDriveTree(adminPage); + + await drive.goTo(); + await drive.openFolder(folderName); + await tree.expectFolderSelected(folderName); + + await drive.selectSystemHost(); + await drive.expectSelectedEntry('system-host'); + + await adminPage.goBack(); + + await tree.expectFolderSelected(folderName); + expect(await drive.isEntrySelected('system-host')).toBe(false); + }); + test('carries the selection in the URL so a reload reopens it @critical', async ({ adminPage }) => { diff --git a/core-web/libs/portlets/dot-content-drive/portlet/src/lib/dot-content-drive-shell/dot-content-drive-shell.component.spec.ts b/core-web/libs/portlets/dot-content-drive/portlet/src/lib/dot-content-drive-shell/dot-content-drive-shell.component.spec.ts index 8f3310dfdf4c..d2d47ce30c93 100644 --- a/core-web/libs/portlets/dot-content-drive/portlet/src/lib/dot-content-drive-shell/dot-content-drive-shell.component.spec.ts +++ b/core-web/libs/portlets/dot-content-drive/portlet/src/lib/dot-content-drive-shell/dot-content-drive-shell.component.spec.ts @@ -3353,6 +3353,24 @@ describe('DotContentDriveShellComponent', () => { expect(workflowService.bulkFire).toHaveBeenCalled(); }); + // In System Host and all site content there is no selected folder at all, so the + // target arrives undefined and the folder-level check has nothing to answer about. + // The store's gate already knows which scope is open and whose permission applies; + // without deferring to it, a drop here is waved through on the site's answer while + // the Upload button beside it is correctly disabled. + it('should refuse a drop with no target when the scope refuses content', () => { + canAddChildrenSignal.set(false); + + store.dragItems.mockReturnValue({ + folders: [], + contentlets: [MOCK_ITEMS[0] as DotCMSContentlet] + }); + const sidebar = spectator.debugElement.query(By.css('[data-testid="sidebar"]')); + spectator.triggerEventHandler(sidebar, 'moveItems', { targetFolder: undefined }); + + expect(workflowService.bulkFire).not.toHaveBeenCalled(); + }); + // The site root carries no permissions of its own, so the store's site-level answer is // what decides there. it('should refuse a move onto the site root when the site refuses content', () => { diff --git a/core-web/libs/portlets/dot-content-drive/portlet/src/lib/dot-content-drive-shell/dot-content-drive-shell.component.ts b/core-web/libs/portlets/dot-content-drive/portlet/src/lib/dot-content-drive-shell/dot-content-drive-shell.component.ts index 72c42f4c61b5..6492ac6c3a73 100644 --- a/core-web/libs/portlets/dot-content-drive/portlet/src/lib/dot-content-drive-shell/dot-content-drive-shell.component.ts +++ b/core-web/libs/portlets/dot-content-drive/portlet/src/lib/dot-content-drive-shell/dot-content-drive-shell.component.ts @@ -1264,7 +1264,16 @@ export class DotContentDriveShellComponent implements OnDestroy { * @returns {boolean} Whether the drop may proceed */ #canDropInto(targetFolder?: DotFolderTreeNodeData): boolean { - if (canAddChildrenTo(targetFolder, this.#store.siteCanAddChildren())) { + // No target means no folder is selected, which is every scope that is not a folder: all + // site content and System Host. `canAddChildrenTo` answers `true` for an absent target + // because it has nothing to judge, so asking it there would wave the drop through on the + // site's answer while the Upload button beside it is correctly disabled. The store's gate + // already knows which scope is open and whose permission applies. + const allowed = targetFolder + ? canAddChildrenTo(targetFolder, this.#store.siteCanAddChildren()) + : this.#store.$canAddChildren(); + + if (allowed) { return true; } diff --git a/core-web/libs/portlets/dot-content-drive/portlet/src/lib/store/dot-content-drive.store.spec.ts b/core-web/libs/portlets/dot-content-drive/portlet/src/lib/store/dot-content-drive.store.spec.ts index b30c1dffd0ef..a1bd44329979 100644 --- a/core-web/libs/portlets/dot-content-drive/portlet/src/lib/store/dot-content-drive.store.spec.ts +++ b/core-web/libs/portlets/dot-content-drive/portlet/src/lib/store/dot-content-drive.store.spec.ts @@ -497,6 +497,43 @@ describe('DotContentDriveStore', () => { expect(request.assetPath).toBe(`//${SYSTEM_HOST.hostname}/`); }); + it('should keep System Host selected when the site is switched', () => { + // **Characterization test: green the day it is written**, like the host-clause + // guard on the backend. System Host belongs to no site, so switching sites does + // not change what it lists, and the selection already survives because it is + // derived from the location while a switch changes the site. + // + // Written precisely because nothing would notice if that stopped being true. A + // later change that reset the path on a site switch would drift the highlight + // onto the new site's root, and the sidebar would claim the user is in two + // places at once — with every other test still passing. + store.initContentDrive({ + currentSite: SYSTEM_HOST, + path: 'SYSTEM_HOST', + filters: {}, + isTreeExpanded: false + }); + expect(store.$systemHostSelected()).toBe(true); + + // A site switch reaches the store as a re-init carrying the new site and the + // location the route still holds — which is how the switch can change the + // site without disturbing where the drive is browsing. + store.initContentDrive({ + currentSite: MOCK_SITES[0], + path: 'SYSTEM_HOST', + filters: {}, + isTreeExpanded: false + }); + + expect(store.$systemHostSelected()).toBe(true); + expect(store.$allSiteContentSelected()).toBe(false); + // The hierarchy below re-renders for the newly chosen site, so the switch + // visibly does something rather than appearing to fail. + expect(store.currentSite()).toEqual(MOCK_SITES[0]); + // And the request still asks for System Host, not for the new site's content. + expect(store.$request().browseScope).toBe('SYSTEM_HOST'); + }); + it('should not ask for folders in all site content', () => { store.initContentDrive({ currentSite: SYSTEM_HOST, diff --git a/core-web/libs/portlets/dot-content-drive/portlet/src/lib/store/features/sidebar/withSidebar.ts b/core-web/libs/portlets/dot-content-drive/portlet/src/lib/store/features/sidebar/withSidebar.ts index ba33a29886f2..269b34267453 100644 --- a/core-web/libs/portlets/dot-content-drive/portlet/src/lib/store/features/sidebar/withSidebar.ts +++ b/core-web/libs/portlets/dot-content-drive/portlet/src/lib/store/features/sidebar/withSidebar.ts @@ -9,7 +9,7 @@ import { import { rxMethod } from '@ngrx/signals/rxjs-interop'; import { Observable, of, pipe, switchMap, tap } from 'rxjs'; -import { inject } from '@angular/core'; +import { effect, EffectRef, inject, untracked } from '@angular/core'; import { catchError } from 'rxjs/operators'; @@ -19,6 +19,7 @@ import { DotFolderTreeNodeItem } from '@dotcms/portlets/content-drive/ui'; import { DEFAULT_PAGE, DEFAULT_PATH, + ROOT_PATH, SYSTEM_HOST, SYSTEM_HOST_PATH } from '../../../shared/constants'; @@ -29,7 +30,11 @@ import { getFolderHierarchyByPath, getFolderNodesByPath } from '../../../utils/functions'; -import { buildTreeFolderNodes, createSiteNode } from '../../../utils/tree-folder.utils'; +import { + buildTreeFolderNodes, + createSiteNode, + findNodeByPath +} from '../../../utils/tree-folder.utils'; interface WithSidebarState { sidebarLoading: boolean; @@ -226,9 +231,42 @@ export function withSidebar() { } })), withHooks((store) => { + let selectionSync: EffectRef | undefined; + return { onInit() { store.loadFolders(); + + // Keeps the tree's selection honest as the location moves. + // + // Folders reload on a site change, not on a Back, so returning to a folder + // restored the URL and the listing while the tree showed nothing selected. + // Everything else in the sidebar derives its selected state from the location + // and was therefore already right; this is the one stored piece, so it has to + // be pushed back in line rather than left holding whatever the previous + // location put there. + // + // Reads `folders()` as well as `path()` on purpose: on a cold start the tree + // is empty when the location is already known, and this has to run again once + // the folders arrive. + selectionSync = effect(() => { + const path = store.path(); + const folders = store.folders(); + const match = path?.startsWith(ROOT_PATH) + ? findNodeByPath(folders, path) + : undefined; + + // Only when it actually differs: a folder click already sets the node, and + // rewriting the same one on every location change churns the tree. + untracked(() => { + if (store.selectedNode()?.data?.path !== match?.data?.path) { + patchState(store, { selectedNode: match }); + } + }); + }); + }, + onDestroy() { + selectionSync?.destroy(); } }; }) diff --git a/core-web/libs/portlets/dot-content-drive/portlet/src/lib/utils/tree-folder.utils.spec.ts b/core-web/libs/portlets/dot-content-drive/portlet/src/lib/utils/tree-folder.utils.spec.ts index 8a2cc8e9fec9..ac36ade407ac 100644 --- a/core-web/libs/portlets/dot-content-drive/portlet/src/lib/utils/tree-folder.utils.spec.ts +++ b/core-web/libs/portlets/dot-content-drive/portlet/src/lib/utils/tree-folder.utils.spec.ts @@ -6,6 +6,7 @@ import { buildTreeFolderNodes, createSiteNode, createTreeNode, + findNodeByPath, generateAllParentPaths } from './tree-folder.utils'; @@ -753,3 +754,59 @@ describe('Sidebar Utils', () => { }); }); }); + +describe('findNodeByPath', () => { + // Shaped like the real tree: the site row on top, its folders as children, nesting below. + const tree = [ + { + key: 'site', + label: 'demo.dotcms.com', + data: { id: 'site-1', path: '', type: 'folder' }, + children: [ + { + key: '/blog/', + label: 'blog', + data: { id: 'f1', path: '/blog/', type: 'folder' }, + children: [ + { + key: '/blog/2026/', + label: '2026', + data: { id: 'f2', path: '/blog/2026/', type: 'folder' }, + children: [] + } + ] + }, + { + key: '/images/', + label: 'images', + data: { id: 'f3', path: '/images/', type: 'folder' }, + children: [] + } + ] + } + ] as never; + + it('should find a folder at the top level', () => { + expect(findNodeByPath(tree, '/images/')?.data?.id).toBe('f3'); + }); + + it('should find a folder nested below another', () => { + // The case the bug turned on: Back can land on any depth, not just a root folder. + expect(findNodeByPath(tree, '/blog/2026/')?.data?.id).toBe('f2'); + }); + + it('should return undefined for a path no folder has', () => { + expect(findNodeByPath(tree, '/nope/')).toBeUndefined(); + }); + + it('should return undefined rather than throwing when the tree is not loaded yet', () => { + // A cold start knows the location before it has any folders, so this is reached on the + // common path rather than as an edge case. + expect(findNodeByPath(undefined, '/blog/')).toBeUndefined(); + }); + + it('should match on the path the URL carries, not the node key', () => { + // Keys encode tree position; the URL carries the path, and the two are not the same thing. + expect(findNodeByPath(tree, 'site')).toBeUndefined(); + }); +}); diff --git a/core-web/libs/portlets/dot-content-drive/portlet/src/lib/utils/tree-folder.utils.ts b/core-web/libs/portlets/dot-content-drive/portlet/src/lib/utils/tree-folder.utils.ts index f9a3c7c9aeb9..242a1b7f170d 100644 --- a/core-web/libs/portlets/dot-content-drive/portlet/src/lib/utils/tree-folder.utils.ts +++ b/core-web/libs/portlets/dot-content-drive/portlet/src/lib/utils/tree-folder.utils.ts @@ -169,3 +169,33 @@ export const buildTreeFolderNodes = ({ return { rootNodes, selectedNode }; }; + +/** + * Finds the node for a folder path in an already-built tree. + * + * The sidebar's two standalone entries derive their selected state from the location, so they are + * always right. The tree's does not: it is stored, and until this existed it was only ever + * recomputed when folders loaded — so a Back that changed the location without reloading folders + * left the tree showing nothing selected while the listing showed that folder's contents. + * + * Matches on the node's own path rather than its key, because the key encodes tree position while + * the path is what the URL carries. + */ +export const findNodeByPath = ( + nodes: DotFolderTreeNodeItem[] | undefined, + path: string +): DotFolderTreeNodeItem | undefined => { + for (const node of nodes ?? []) { + if (node.data?.path === path) { + return node; + } + + const found = findNodeByPath(node.children as DotFolderTreeNodeItem[] | undefined, path); + + if (found) { + return found; + } + } + + return undefined; +}; diff --git a/dotcms-postman/src/main/resources/postman/ContentDriveResource.postman_collection.json b/dotcms-postman/src/main/resources/postman/ContentDriveResource.postman_collection.json index ec1c45345966..74f71b3797a1 100644 --- a/dotcms-postman/src/main/resources/postman/ContentDriveResource.postman_collection.json +++ b/dotcms-postman/src/main/resources/postman/ContentDriveResource.postman_collection.json @@ -1,2299 +1,2509 @@ { - "info": { - "_postman_id": "7d861cdb-18a9-4c48-9df0-a7d1a99406c6", - "name": "Content Drive", - "description": "Comprehensive tests for Content Drive search functionality including pagination, sorting, and filtering tests.", - "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json", - "_exporter_id": "5403727" - }, - "item": [ - { - "name": "Test Data Setup", - "item": [ - { - "name": "Create Test Site", - "event": [ - { - "listen": "test", - "script": { - "exec": [ - "pm.test(\"Status code should be 200\", function () {", - " pm.response.to.have.status(200);", - "});", - "", - "var jsonData = pm.response.json();", - "pm.collectionVariables.set(\"testSiteId\", jsonData.entity.identifier);", - "pm.collectionVariables.set(\"testSiteName\", jsonData.entity.siteName);", - "", - "pm.test(\"Site created successfully\", function () {", - " pm.expect(jsonData.entity.siteName).to.eql('contentdrive.test.site');", - " pm.expect(jsonData.entity.identifier).to.not.be.empty;", - "});" - ], - "type": "text/javascript", - "packages": {}, - "requests": {} - } - } - ], - "request": { - "method": "POST", - "header": [ - { - "key": "Content-Type", - "value": "application/json" - } - ], - "body": { - "mode": "raw", - "raw": "{\n \"siteName\": \"contentdrive.test.site\"\n}", - "options": { - "raw": { - "language": "json" - } - } - }, - "url": { - "raw": "{{serverURL}}/api/v1/site", - "host": [ - "{{serverURL}}" - ], - "path": [ - "api", - "v1", - "site" - ] - } - }, - "response": [] - }, - { - "name": "Create Test Content Type for Drive Search", - "event": [ - { - "listen": "test", - "script": { - "exec": [ - "pm.test(\"Status code should be 200\", function () {", - " pm.response.to.have.status(200);", - "});", - "", - "var jsonData = pm.response.json();", - "pm.collectionVariables.set(\"testContentTypeId\", jsonData.entity[0].id);", - "pm.collectionVariables.set(\"testContentTypeVar\", jsonData.entity[0].variable);", - "", - "pm.test(\"Content type created with title field\", function () {", - " pm.expect(jsonData.entity[0].variable).to.include('driveSearchTest');", - " pm.expect(jsonData.entity[0].fields).to.have.length.at.least(1);", - " // Check for title field", - " var titleField = jsonData.entity[0].fields.find(f => f.variable === 'title');", - " pm.expect(titleField).to.not.be.undefined;", - "});" - ], - "type": "text/javascript" - } - } - ], - "request": { - "method": "POST", - "header": [ - { - "key": "Content-Type", - "value": "application/json" - } - ], - "body": { - "mode": "raw", - "raw": "{\n \"clazz\": \"com.dotcms.contenttype.model.type.SimpleContentType\",\n \"description\": \"Content Type for Drive Search Tests\",\n \"defaultType\": false,\n \"system\": false,\n \"folder\": \"SYSTEM_FOLDER\",\n \"name\": \"Drive Search Test {{$randomBankAccount}}\",\n \"variable\": \"driveSearchTest{{$randomBankAccount}}\",\n \"host\": \"SYSTEM_HOST\",\n \"fixed\": false,\n \"fields\": [\n {\n \"clazz\": \"com.dotcms.contenttype.model.field.TextField\",\n \"indexed\": true,\n \"dataType\": \"TEXT\",\n \"readOnly\": false,\n \"required\": true,\n \"searchable\": true,\n \"listed\": true,\n \"sortOrder\": 1,\n \"unique\": false,\n \"name\": \"Title\",\n \"variable\": \"title\",\n \"fixed\": true\n },\n {\n \"clazz\": \"com.dotcms.contenttype.model.field.TextAreaField\",\n \"indexed\": true,\n \"dataType\": \"LONG_TEXT\",\n \"readOnly\": false,\n \"required\": false,\n \"searchable\": true,\n \"listed\": false,\n \"sortOrder\": 2,\n \"unique\": false,\n \"name\": \"Body\",\n \"variable\": \"body\"\n }\n ],\n \"workflow\": [\"d61a59e1-a49c-46f2-a929-db2b4bfa88b2\"]\n}", - "options": { - "raw": { - "language": "json" - } - } - }, - "url": { - "raw": "{{serverURL}}/api/v1/contenttype", - "host": [ - "{{serverURL}}" - ], - "path": [ - "api", - "v1", - "contenttype" - ] - } - }, - "response": [] - }, - { - "name": "Create Test Folders", - "event": [ - { - "listen": "test", - "script": { - "exec": [ - "pm.test(\"Status code should be 200\", function () {", - " pm.response.to.have.status(200);", - "});", - "", - "var jsonData = pm.response.json();", - "pm.collectionVariables.set(\"testFolderId\", jsonData.entity.identifier);", - "", - "pm.test(\"Test folder created\", function () {", - " pm.expect(jsonData.entity[0].name).to.eql('drive-test-folder');", - "});" - ], - "type": "text/javascript", - "packages": {}, - "requests": {} - } - } - ], - "request": { - "method": "POST", - "header": [ - { - "key": "Content-Type", - "value": "application/json" - } - ], - "body": { - "mode": "raw", - "raw": "\n [\"/drive-test-folder/\"]\n", - "options": { - "raw": { - "language": "json" - } - } - }, - "url": { - "raw": "{{serverURL}}/api/v1/folder/createfolders/{{testSiteName}}", - "host": [ - "{{serverURL}}" - ], - "path": [ - "api", - "v1", - "folder", - "createfolders", - "{{testSiteName}}" - ] - } - }, - "response": [] - }, - { - "name": "Create Test Contentlets - Alpha Items", - "event": [ - { - "listen": "test", - "script": { - "exec": [ - "pm.test(\"Status code should be 200\", function () {", - " pm.response.to.have.status(200);", - "});", - "", - "var jsonData = pm.response.json();", - "pm.test(\"Alpha contentlets created\", function () {", - " pm.expect(jsonData.errors.length).to.eql(0);", - " pm.expect(jsonData.entity.results.length).to.eql(3);", - " ", - " // Store contentlet identifiers for cleanup", - " jsonData.entity.results.forEach((result, index) => {", - " var contentId = Object.keys(result)[0];", - " pm.collectionVariables.set(`alphaContentlet${index+1}Id`, result[contentId].identifier);", - " });", - "});" - ], - "type": "text/javascript" - } - } - ], - "request": { - "method": "POST", - "header": [ - { - "key": "Content-Type", - "value": "application/json" - } - ], - "body": { - "mode": "raw", - "raw": "{\n \"contentlets\": [\n {\n \"contentType\": \"{{testContentTypeVar}}\",\n \"title\": \"Alpha Test Content\",\n \"contentHost\": \"{{testSiteId}}\",\n \"body\": \"This is alpha test content for drive search\"\n },\n {\n \"contentType\": \"{{testContentTypeVar}}\",\n \"title\": \"Apple Product Review\",\n \"contentHost\": \"{{testSiteId}}\",\n \"body\": \"Detailed review of Apple products\"\n },\n {\n \"contentType\": \"{{testContentTypeVar}}\",\n \"title\": \"Analytics Dashboard Guide\",\n \"contentHost\": \"{{testSiteId}}\",\n \"body\": \"How to use analytics dashboard effectively\"\n }\n ]\n}", - "options": { - "raw": { - "language": "json" - } - } - }, - "url": { - "raw": "{{serverURL}}/api/v1/workflow/actions/default/fire/PUBLISH", - "host": [ - "{{serverURL}}" - ], - "path": [ - "api", - "v1", - "workflow", - "actions", - "default", - "fire", - "PUBLISH" - ] - } - }, - "response": [] - }, - { - "name": "Create Test Contentlets - Beta Items", - "event": [ - { - "listen": "test", - "script": { - "exec": [ - "pm.test(\"Status code should be 200\", function () {", - " pm.response.to.have.status(200);", - "});", - "", - "var jsonData = pm.response.json();", - "pm.test(\"Beta contentlets created\", function () {", - " pm.expect(jsonData.errors.length).to.eql(0);", - " pm.expect(jsonData.entity.results.length).to.eql(3);", - " ", - " // Store contentlet identifiers for cleanup", - " jsonData.entity.results.forEach((result, index) => {", - " var contentId = Object.keys(result)[0];", - " pm.collectionVariables.set(`betaContentlet${index+1}Id`, result[contentId].identifier);", - " });", - "});" - ], - "type": "text/javascript" - } - } - ], - "request": { - "method": "POST", - "header": [ - { - "key": "Content-Type", - "value": "application/json" - } - ], - "body": { - "mode": "raw", - "raw": "{\n \"contentlets\": [\n {\n \"contentType\": \"{{testContentTypeVar}}\",\n \"title\": \"Beta Testing Framework\",\n \"contentHost\": \"{{testSiteId}}\",\n \"body\": \"Comprehensive beta testing framework documentation\"\n },\n {\n \"contentType\": \"{{testContentTypeVar}}\",\n \"title\": \"Business Intelligence Report\",\n \"contentHost\": \"{{testSiteId}}\",\n \"body\": \"Monthly business intelligence report\"\n },\n {\n \"contentType\": \"{{testContentTypeVar}}\",\n \"title\": \"Best Practices Guide\",\n \"contentHost\": \"{{testSiteId}}\",\n \"body\": \"Development best practices and guidelines\"\n }\n ]\n}", - "options": { - "raw": { - "language": "json" - } - } - }, - "url": { - "raw": "{{serverURL}}/api/v1/workflow/actions/default/fire/PUBLISH", - "host": [ - "{{serverURL}}" - ], - "path": [ - "api", - "v1", - "workflow", - "actions", - "default", - "fire", - "PUBLISH" - ] - } - }, - "response": [] - }, - { - "name": "Create Additional Test Folders", - "event": [ - { - "listen": "test", - "script": { - "exec": [ - "pm.test(\"Status code should be 200\", function () {", - " pm.response.to.have.status(200);", - "});", - "", - "var jsonData = pm.response.json();", - "pm.test(\"Additional folders created\", function () {", - " pm.expect(jsonData.entity[0].name).to.include('subfolder');", - "});" - ], - "type": "text/javascript", - "packages": {}, - "requests": {} - } - } - ], - "request": { - "method": "POST", - "header": [ - { - "key": "Content-Type", - "value": "application/json" - } - ], - "body": { - "mode": "raw", - "raw": "[\"/drive-test-folder/alpha-subfolder/\"]", - "options": { - "raw": { - "language": "json" - } - } - }, - "url": { - "raw": "{{serverURL}}/api/v1/folder/createfolders/{{testSiteName}}", - "host": [ - "{{serverURL}}" - ], - "path": [ - "api", - "v1", - "folder", - "createfolders", - "{{testSiteName}}" - ] - } - }, - "response": [] - }, - { - "name": "Create Beta Subfolder", - "event": [ - { - "listen": "test", - "script": { - "exec": [ - "pm.test(\"Status code should be 200\", function () {", - " pm.response.to.have.status(200);", - "});", - "", - "var jsonData = pm.response.json();", - "pm.test(\"Beta subfolder created\", function () {", - " pm.expect(jsonData.entity[0].name).to.include('beta-subfolder');", - "});" - ], - "type": "text/javascript", - "packages": {}, - "requests": {} - } - } - ], - "request": { - "method": "POST", - "header": [ - { - "key": "Content-Type", - "value": "application/json" - } - ], - "body": { - "mode": "raw", - "raw": " [\"/drive-test-folder/beta-subfolder/\"]", - "options": { - "raw": { - "language": "json" - } - } - }, - "url": { - "raw": "{{serverURL}}/api/v1/folder/createfolders/{{testSiteName}}", - "host": [ - "{{serverURL}}" - ], - "path": [ - "api", - "v1", - "folder", - "createfolders", - "{{testSiteName}}" - ] - } - }, - "response": [] - }, - { - "name": "Create Image File", - "request": { - "method": "PUT", - "header": [], - "body": { - "mode": "formdata", - "formdata": [ - { - "key": "file", - "type": "file", - "src": "resources/image/Landscape_2008_urban_park_and_plaza_Ankaran.jpeg" - }, - { - "key": "json", - "value": "{\n \"contentlet\": {\n \"contentType\":\"FileAsset\",\n \"title\":\"Test Image\",\n \"hostFolder\":\"{{testSiteName}}\"\n }\n}", - "type": "text" - }, - { - "key": "", - "value": "", - "type": "text", - "disabled": true - } - ] - }, - "url": { - "raw": "{{serverURL}}/api/v1/workflow/actions/default/fire/PUBLISH?language=1", - "host": [ - "{{serverURL}}" - ], - "path": [ - "api", - "v1", - "workflow", - "actions", - "default", - "fire", - "PUBLISH" - ], - "query": [ - { - "key": "language", - "value": "1" - } - ] - } - }, - "response": [] - } - ], - "description": "Sets up test data including sites, content types, folders, and contentlets for drive search testing." - }, - { - "name": "Pagination Tests", - "item": [ - { - "name": "Basic Pagination - First Page", - "event": [ - { - "listen": "test", - "script": { - "exec": [ - "pm.test(\"Status code should be 200\", function () {", - " pm.response.to.have.status(200);", - "});", - "", - "pm.test(\"Response should have pagination structure\", function () {", - " var jsonData = pm.response.json();", - " pm.expect(jsonData).to.have.property('entity');", - " pm.expect(jsonData.entity).to.have.property('list');", - " pm.expect(jsonData.entity).to.have.property('folderCount');", - " pm.expect(jsonData.entity).to.have.property('contentCount');", - "});", - "", - "pm.test(\"First page results within limit\", function () {", - " var jsonData = pm.response.json();", - " var list = jsonData.entity.list;", - " pm.expect(list.length).to.be.at.most(5); // maxResults limit", - " pm.expect(list.length).to.be.at.least(1);", - "});", - "", - "pm.test(\"Contains both folders and contentlets\", function () {", - " var jsonData = pm.response.json();", - " var folderCount = jsonData.entity.folderCount;", - " var contentCount = jsonData.entity.contentCount;", - " pm.expect(folderCount + contentCount).to.be.at.least(1);", - "});" - ], - "type": "text/javascript" - } - } - ], - "request": { - "method": "POST", - "header": [ - { - "key": "Content-Type", - "value": "application/json" - } - ], - "body": { - "mode": "raw", - "raw": "{\n \"assetPath\": \"//{{testSiteName}}/\",\n \"contentCursor\": 0,\n \"maxResults\": 5\n}", - "options": { - "raw": { - "language": "json" - } - } - }, - "url": { - "raw": "{{serverURL}}/api/v1/drive/search", - "host": [ - "{{serverURL}}" - ], - "path": [ - "api", - "v1", - "drive", - "search" - ] - } - }, - "response": [] - }, - { - "name": "Pagination - Second Page", - "event": [ - { - "listen": "test", - "script": { - "exec": [ - "pm.test(\"Status code should be 200\", function () {", - " pm.response.to.have.status(200);", - "});", - "", - "pm.test(\"Second page contentCursor handled correctly\", function () {", - " var jsonData = pm.response.json();", - " var list = jsonData.entity.list;", - " // Should have remaining results or be empty if total <= 5", - " pm.expect(list.length).to.be.at.most(5);", - "});", - "", - "pm.test(\"Pagination metadata consistent\", function () {", - " var jsonData = pm.response.json();", - " pm.expect(jsonData.entity.folderCount).to.be.a('number');", - " pm.expect(jsonData.entity.contentCount).to.be.a('number');", - "});" - ], - "type": "text/javascript" - } - } - ], - "request": { - "method": "POST", - "header": [ - { - "key": "Content-Type", - "value": "application/json" - } - ], - "body": { - "mode": "raw", - "raw": "{\n \"assetPath\": \"//{{testSiteName}}/\",\n \"contentCursor\": 5,\n \"maxResults\": 5\n}", - "options": { - "raw": { - "language": "json" - } - } - }, - "url": { - "raw": "{{serverURL}}/api/v1/drive/search", - "host": [ - "{{serverURL}}" - ], - "path": [ - "api", - "v1", - "drive", - "search" - ] - } - }, - "response": [] - }, - { - "name": "Large MaxResults Test", - "event": [ - { - "listen": "test", - "script": { - "exec": [ - "pm.test(\"Status code should be 200\", function () {", - " pm.response.to.have.status(200);", - "});", - "", - "pm.test(\"Large maxResults respected\", function () {", - " var jsonData = pm.response.json();", - " var list = jsonData.entity.list;", - " pm.expect(list.length).to.be.at.most(50); // Should not exceed total available", - "});", - "", - "pm.test(\"All items returned when maxResults is large\", function () {", - " var jsonData = pm.response.json();", - " var totalExpected = jsonData.entity.folderCount + jsonData.entity.contentCount;", - " pm.expect(jsonData.entity.list.length).to.be.at.most(totalExpected);", - "});" - ], - "type": "text/javascript" - } - } - ], - "request": { - "method": "POST", - "header": [ - { - "key": "Content-Type", - "value": "application/json" - } - ], - "body": { - "mode": "raw", - "raw": "{\n \"assetPath\": \"//{{testSiteName}}/\",\n \"contentCursor\": 0,\n \"maxResults\": 50\n}", - "options": { - "raw": { - "language": "json" - } - } - }, - "url": { - "raw": "{{serverURL}}/api/v1/drive/search", - "host": [ - "{{serverURL}}" - ], - "path": [ - "api", - "v1", - "drive", - "search" - ] - } - }, - "response": [] - } - ], - "description": "Tests pagination functionality with different contentCursor and maxResults values." - }, - { - "name": "Sorting Tests", - "item": [ - { - "name": "Sort by ModDate (Default)", - "event": [ - { - "listen": "test", - "script": { - "exec": [ - "pm.test(\"Status code should be 200\", function () {", - " pm.response.to.have.status(200);", - "});", - "", - "pm.test(\"Results are sorted by modification date\", function () {", - " var jsonData = pm.response.json();", - " var list = jsonData.entity.list;", - " ", - " if (list.length > 1) {", - " for (let i = 0; i < list.length - 1; i++) {", - " if (list[i].modDate && list[i+1].modDate) {", - " var date1 = new Date(list[i].modDate);", - " var date2 = new Date(list[i+1].modDate);", - " // date1 should be LESS THAN OR EQUAL to date2 (ascending)", - " pm.expect(date1.getTime()).to.be.at.most(date2.getTime());", - " }", - " }", - " }", - "});", - "", - "pm.test(\"Mixed content types present\", function () {", - " var jsonData = pm.response.json();", - " var folderCount = jsonData.entity.folderCount;", - " var contentCount = jsonData.entity.contentCount;", - " pm.expect(folderCount + contentCount).to.be.at.least(1);", - "});" - ], - "type": "text/javascript", - "packages": {}, - "requests": {} - } - } - ], - "request": { - "method": "POST", - "header": [ - { - "key": "Content-Type", - "value": "application/json" - } - ], - "body": { - "mode": "raw", - "raw": "{\n \"assetPath\": \"//{{testSiteName}}/\",\n \"sortBy\": \"modDate\",\n \"showFolders\": false,\n \"maxResults\": 10\n}", - "options": { - "raw": { - "language": "json" - } - } - }, - "url": { - "raw": "{{serverURL}}/api/v1/drive/search", - "host": [ - "{{serverURL}}" - ], - "path": [ - "api", - "v1", - "drive", - "search" - ] - } - }, - "response": [] - }, - { - "name": "Sort by Title Ascending", - "event": [ - { - "listen": "test", - "script": { - "exec": [ - "pm.test(\"Status code should be 200\", function () {", - " pm.response.to.have.status(200);", - "});", - "", - "pm.test(\"Results sorted by title ascending\", function () {", - " var jsonData = pm.response.json();", - " var list = jsonData.entity.list;", - " ", - " if (list.length > 1) {", - " // Extract contentlets with titles for sorting verification", - " var contentletsWithTitles = list.filter(item => item.title || item.name);", - " ", - " if (contentletsWithTitles.length > 1) {", - " for (let i = 0; i < contentletsWithTitles.length - 1; i++) {", - " var title1 = (contentletsWithTitles[i].title || contentletsWithTitles[i].name).toLowerCase();", - " var title2 = (contentletsWithTitles[i+1].title || contentletsWithTitles[i+1].name).toLowerCase();", - " pm.expect(title1.localeCompare(title2)).to.be.at.most(0);", - " }", - " }", - " }", - "});" - ], - "type": "text/javascript", - "packages": {}, - "requests": {} - } - } - ], - "request": { - "method": "POST", - "header": [ - { - "key": "Content-Type", - "value": "application/json" - } - ], - "body": { - "mode": "raw", - "raw": "{\n \"assetPath\": \"//{{testSiteName}}/\",\n \"sortBy\": \"title:asc\",\n \"showFolders\": false, \n \"maxResults\": 10\n}", - "options": { - "raw": { - "language": "json" - } - } - }, - "url": { - "raw": "{{serverURL}}/api/v1/drive/search", - "host": [ - "{{serverURL}}" - ], - "path": [ - "api", - "v1", - "drive", - "search" - ] - } - }, - "response": [] - }, - { - "name": "Sort by Title Descending", - "event": [ - { - "listen": "test", - "script": { - "exec": [ - "pm.test(\"Status code should be 200\", function () {", - " pm.response.to.have.status(200);", - "});", - "", - "pm.test(\"Results sorted by title descending\", function () {", - " var jsonData = pm.response.json();", - " var list = jsonData.entity.list;", - " ", - " if (list.length > 1) {", - " // Extract contentlets with titles for sorting verification", - " var contentletsWithTitles = list.filter(item => item.title || item.name);", - " ", - " if (contentletsWithTitles.length > 1) {", - " for (let i = 0; i < contentletsWithTitles.length - 1; i++) {", - " var title1 = (contentletsWithTitles[i].title || contentletsWithTitles[i].name).toLowerCase();", - " var title2 = (contentletsWithTitles[i+1].title || contentletsWithTitles[i+1].name).toLowerCase();", - " pm.expect(title1.localeCompare(title2)).to.be.at.least(0);", - " }", - " }", - " }", - "});" - ], - "type": "text/javascript", - "packages": {}, - "requests": {} - } - } - ], - "request": { - "method": "POST", - "header": [ - { - "key": "Content-Type", - "value": "application/json" - } - ], - "body": { - "mode": "raw", - "raw": "{\n \"assetPath\": \"//{{testSiteName}}/\",\n \"sortBy\": \"title:desc\",\n \"showFolders\": false, \n \"maxResults\": 10\n}", - "options": { - "raw": { - "language": "json" - } - } - }, - "url": { - "raw": "{{serverURL}}/api/v1/drive/search", - "host": [ - "{{serverURL}}" - ], - "path": [ - "api", - "v1", - "drive", - "search" - ] - } - }, - "response": [] - }, - { - "name": "Folder and Content Mixed Sorting", - "event": [ - { - "listen": "test", - "script": { - "exec": [ - "pm.test(\"Status code should be 200\", function () {", - " pm.response.to.have.status(200);", - "});", - "", - "pm.test(\"Folders and contentlets properly mixed in sort\", function () {", - " var jsonData = pm.response.json();", - " var list = jsonData.entity.list;", - " var folderCount = jsonData.entity.folderCount;", - " var contentCount = jsonData.entity.contentCount;", - " ", - " pm.expect(folderCount + contentCount).to.be.at.least(1);", - " ", - " // Verify that both folders and content can appear in the list", - " if (list.length > 0) {", - " var hasFolders = list.some(item => item.type === 'folder' || item.__icon__ === 'folder');", - " var hasContent = list.some(item => item.contentType || item.type === 'contentlet');", - " pm.expect(hasFolders || hasContent).to.be.true;", - " }", - "});", - "", - "pm.test(\"Results include test folder and test content\", function () {", - " var jsonData = pm.response.json();", - " var list = jsonData.entity.list;", - " ", - " // Look for our test items", - " var hasTestFolder = list.some(item => ", - " (item.name && item.name.includes('drive-test-folder')) ||", - " (item.title && item.title.includes('drive-test-folder'))", - " );", - " var hasTestContent = list.some(item => ", - " (item.title && (item.title.includes('Alpha') || item.title.includes('Beta'))) ||", - " (item.name && (item.name.includes('Alpha') || item.name.includes('Beta')))", - " );", - " ", - " // At least one should be present", - " pm.expect(hasTestFolder || hasTestContent).to.be.true;", - "});" - ], - "type": "text/javascript", - "packages": {}, - "requests": {} - } - } - ], - "request": { - "method": "POST", - "header": [ - { - "key": "Content-Type", - "value": "application/json" - } - ], - "body": { - "mode": "raw", - "raw": "{\n \"assetPath\": \"//{{testSiteName}}/\",\n \"sortBy\": \"title:asc\",\n \"showFolders\": true, \n \"maxResults\": 20\n}", - "options": { - "raw": { - "language": "json" - } - } - }, - "url": { - "raw": "{{serverURL}}/api/v1/drive/search", - "host": [ - "{{serverURL}}" - ], - "path": [ - "api", - "v1", - "drive", - "search" - ] - } - }, - "response": [] - } - ], - "description": "Tests various sorting options and their behavior with folders and contentlets." - }, - { - "name": "Search and Filtering Tests", - "item": [ - { - "name": "Text Search - Alpha Filter", - "event": [ - { - "listen": "test", - "script": { - "exec": [ - "pm.test(\"Status code should be 200\", function () {", - " pm.response.to.have.status(200);", - "});", - "", - "pm.test(\"Search results contain Alpha items\", function () {", - " var jsonData = pm.response.json();", - " var list = jsonData.entity.list;", - " ", - " pm.expect(list.length).to.be.at.least(1);", - " ", - " // Verify that results contain 'Alpha' in title or name", - " var hasAlphaItems = list.some(item => {", - " var title = item.title || item.name || '';", - " return title.toLowerCase().includes('alpha');", - " });", - " ", - " pm.expect(hasAlphaItems).to.be.true;", - "});", - "", - "pm.test(\"Search filters out non-matching items\", function () {", - " var jsonData = pm.response.json();", - " var list = jsonData.entity.list;", - " ", - " // Should not include items without 'Alpha' when filtering", - " var allItemsMatch = list.every(item => {", - " var title = item.title || item.name || '';", - " var body = item.body || '';", - " return title.toLowerCase().includes('alpha') || body.toLowerCase().includes('alpha');", - " });", - " ", - " // Note: Due to Elasticsearch behavior, this might not be 100% strict", - " // but most results should match", - " pm.expect(list.length).to.be.at.most(10); // Should be filtered down", - "});" - ], - "type": "text/javascript" - } - } - ], - "request": { - "method": "POST", - "header": [ - { - "key": "Content-Type", - "value": "application/json" - } - ], - "body": { - "mode": "raw", - "raw": "{\n \"assetPath\": \"//{{testSiteName}}/\",\n \"filters\": {\n \"text\": \"Alpha\"\n },\n \"maxResults\": 10\n}", - "options": { - "raw": { - "language": "json" - } - } - }, - "url": { - "raw": "{{serverURL}}/api/v1/drive/search", - "host": [ - "{{serverURL}}" - ], - "path": [ - "api", - "v1", - "drive", - "search" - ] - } - }, - "response": [] - }, - { - "name": "Text Search - Beta Filter", - "event": [ - { - "listen": "test", - "script": { - "exec": [ - "pm.test(\"Status code should be 200\", function () {", - " pm.response.to.have.status(200);", - "});", - "", - "pm.test(\"Search results contain Beta items\", function () {", - " var jsonData = pm.response.json();", - " var list = jsonData.entity.list;", - " ", - " pm.expect(list.length).to.be.at.least(1);", - " ", - " // Verify that results contain 'Beta' in title or name", - " var hasBetaItems = list.some(item => {", - " var title = item.title || item.name || '';", - " return title.toLowerCase().includes('beta');", - " });", - " ", - " pm.expect(hasBetaItems).to.be.true;", - "});", - "", - "pm.test(\"Search with different terms returns different results\", function () {", - " var jsonData = pm.response.json();", - " var list = jsonData.entity.list;", - " ", - " // Store result count for comparison in collection variable", - " pm.collectionVariables.set('betaSearchResultCount', list.length);", - " ", - " pm.expect(list.length).to.be.at.most(10);", - "});" - ], - "type": "text/javascript" - } - } - ], - "request": { - "method": "POST", - "header": [ - { - "key": "Content-Type", - "value": "application/json" - } - ], - "body": { - "mode": "raw", - "raw": "{\n \"assetPath\": \"//{{testSiteName}}/\",\n \"filters\": {\n \"text\": \"Beta\"\n },\n \"maxResults\": 10\n}", - "options": { - "raw": { - "language": "json" - } - } - }, - "url": { - "raw": "{{serverURL}}/api/v1/drive/search", - "host": [ - "{{serverURL}}" - ], - "path": [ - "api", - "v1", - "drive", - "search" - ] - } - }, - "response": [] - }, - { - "name": "Text Search - Partial Match", - "event": [ - { - "listen": "test", - "script": { - "exec": [ - "pm.test(\"Status code should be 200\", function () {", - " pm.response.to.have.status(200);", - "});", - "", - "pm.test(\"Partial search works (Test keyword)\", function () {", - " var jsonData = pm.response.json();", - " var list = jsonData.entity.list;", - " ", - " pm.expect(list.length).to.be.at.least(1);", - " ", - " // Verify that results contain 'Test' in title or name", - " var hasTestItems = list.some(item => {", - " var title = item.title || item.name || '';", - " return title.toLowerCase().includes('test');", - " });", - " ", - " pm.expect(hasTestItems).to.be.true;", - "});", - "", - "pm.test(\"Elasticsearch filtering active\", function () {", - " var jsonData = pm.response.json();", - " // When filtering is active, results should be focused", - " pm.expect(jsonData.entity.list.length).to.be.at.most(15);", - "});" - ], - "type": "text/javascript" - } - } - ], - "request": { - "method": "POST", - "header": [ - { - "key": "Content-Type", - "value": "application/json" - } - ], - "body": { - "mode": "raw", - "raw": "{\n \"assetPath\": \"//{{testSiteName}}/\",\n \"filters\": {\n \"text\": \"Test\"\n },\n \"maxResults\": 10\n}", - "options": { - "raw": { - "language": "json" - } - } - }, - "url": { - "raw": "{{serverURL}}/api/v1/drive/search", - "host": [ - "{{serverURL}}" - ], - "path": [ - "api", - "v1", - "drive", - "search" - ] - } - }, - "response": [] - }, - { - "name": "Search with Empty Filter", - "event": [ - { - "listen": "test", - "script": { - "exec": [ - "pm.test(\"Status code should be 200\", function () {", - " pm.response.to.have.status(200);", - "});", - "", - "pm.test(\"Empty filter returns all results\", function () {", - " var jsonData = pm.response.json();", - " var list = jsonData.entity.list;", - " ", - " // Empty filter should return more results than specific searches", - " pm.expect(list.length).to.be.at.least(1);", - " ", - " // Should include both folders and contentlets", - " var folderCount = jsonData.entity.folderCount;", - " var contentCount = jsonData.entity.contentCount;", - " pm.expect(folderCount + contentCount).to.be.at.least(1);", - "});" - ], - "type": "text/javascript" - } - } - ], - "request": { - "method": "POST", - "header": [ - { - "key": "Content-Type", - "value": "application/json" - } - ], - "body": { - "mode": "raw", - "raw": "{\n \"assetPath\": \"//{{testSiteName}}/\",\n \"filters\": {\n \"text\": \"\"\n },\n \"maxResults\": 15\n}", - "options": { - "raw": { - "language": "json" - } - } - }, - "url": { - "raw": "{{serverURL}}/api/v1/drive/search", - "host": [ - "{{serverURL}}" - ], - "path": [ - "api", - "v1", - "drive", - "search" - ] - } - }, - "response": [] - }, - { - "name": "Search No Results", - "event": [ - { - "listen": "test", - "script": { - "exec": [ - "pm.test(\"Status code should be 200\", function () {", - " pm.response.to.have.status(200);", - "});", - "", - "pm.test(\"Non-matching search returns minimal results\", function () {", - " var jsonData = pm.response.json();", - " var list = jsonData.entity.list;", - " ", - " // Should return very few or no results", - " pm.expect(list.length).to.be.at.most(2);", - "});", - "", - "pm.test(\"Response structure maintained even with no results\", function () {", - " var jsonData = pm.response.json();", - " pm.expect(jsonData.entity).to.have.property('list');", - " pm.expect(jsonData.entity).to.have.property('folderCount');", - " pm.expect(jsonData.entity).to.have.property('contentCount');", - "});" - ], - "type": "text/javascript" - } - } - ], - "request": { - "method": "POST", - "header": [ - { - "key": "Content-Type", - "value": "application/json" - } - ], - "body": { - "mode": "raw", - "raw": "{\n \"assetPath\": \"//{{testSiteName}}/\",\n \"filters\": {\n \"text\": \"NonExistentSearchTermThatShouldReturnNoResults12345\"\n },\n \"maxResults\": 10\n}", - "options": { - "raw": { - "language": "json" - } - } - }, - "url": { - "raw": "{{serverURL}}/api/v1/drive/search", - "host": [ - "{{serverURL}}" - ], - "path": [ - "api", - "v1", - "drive", - "search" - ] - } - }, - "response": [] - }, - { - "name": "Search - MIME Type Filter", - "event": [ - { - "listen": "test", - "script": { - "exec": [ - "pm.test(\"Status code should be 200\", function () {", - " pm.response.to.have.status(200);", - "});", - "", - "pm.test(\"Verify results contain 'Test Image'\", function () {", - " const jsonData = pm.response.json();", - " const list = jsonData.entity.list;", - " pm.expect(list.length).to.be.at.least(1);", - " ", - " // Verify that results contain 'Test Image' in its title. This file asset ", - " // is 'Landscape_2008_urban_park_and_plaza_Ankaran.jpeg', created in the Test Data Setup", - " const hasTestImage = list.some(item => {", - " return item.title.toLowerCase().includes('test image');", - " });", - " ", - " pm.expect(hasTestImage).to.equal(true, \"Expected test image is not being returned\");", - "});", - "" - ], - "type": "text/javascript", - "packages": {}, - "requests": {} - } - } - ], - "request": { - "method": "POST", - "header": [ - { - "key": "Content-Type", - "value": "application/json" - } - ], - "body": { - "mode": "raw", - "raw": "{\n \"assetPath\": \"//{{testSiteName}}/\",\n \"mimeTypes\": [\n \"image\"\n ],\n \"maxResults\": 10\n}", - "options": { - "raw": { - "language": "json" - } - } - }, - "url": { - "raw": "{{serverURL}}/api/v1/drive/search", - "host": [ - "{{serverURL}}" - ], - "path": [ - "api", - "v1", - "drive", - "search" - ] - } - }, - "response": [] - } - ], - "description": "Tests search and filtering functionality using the filters.text parameter." - }, - { - "name": "Combined Tests", - "item": [ - { - "name": "Search with Sorting and Pagination", - "event": [ - { - "listen": "test", - "script": { - "exec": [ - "pm.test(\"Status code should be 200\", function () {", - " pm.response.to.have.status(200);", - "});", - "", - "pm.test(\"Combined search, sort and pagination works\", function () {", - " var jsonData = pm.response.json();", - " var list = jsonData.entity.list;", - " ", - " // Should respect maxResults", - " pm.expect(list.length).to.be.at.most(3);", - " ", - " // Should contain filtered results", - " if (list.length > 0) {", - " var hasFilteredItems = list.some(item => {", - " var title = item.title || item.name || '';", - " return title.toLowerCase().includes('test') || title.toLowerCase().includes('alpha') || title.toLowerCase().includes('beta');", - " });", - " pm.expect(hasFilteredItems).to.be.true;", - " }", - "});", - "", - "pm.test(\"Results are sorted: Folders first (by name), then Content (by title)\", function () {", - " var jsonData = pm.response.json();", - " var list = jsonData.entity.list;", - "", - " if (list && list.length > 1) {", - " // 1. Extract what we need into a clean, simplified array", - " var actualOrder = list.map(function(item) {", - " // Determine if it is a folder based on the \"type\" property", - " var isFolder = item.type === 'folder';", - " ", - " // Pick the correct string to sort by", - " var sortString = isFolder ? item.name : item.title;", - " ", - " return {", - " type: isFolder ? 'folder' : 'content',", - " text: (sortString || '').toString().toLowerCase()", - " };", - " });", - "", - " // 2. Create a copy of the actual order to sort ourselves", - " // We use JSON parse/stringify as a quick way to deep clone the array in Postman", - " var expectedOrder = JSON.parse(JSON.stringify(actualOrder));", - "", - " // 3. Apply your custom sorting rules to our expected array", - " expectedOrder.sort(function(a, b) {", - " // Rule A: Folders always come before Content", - " if (a.type === 'folder' && b.type === 'content') return -1;", - " if (a.type === 'content' && b.type === 'folder') return 1;", - "", - " // Rule B: If they are the SAME type, sort alphabetically", - " return a.text.localeCompare(b.text);", - " });", - "", - " // 4. Compare what the API gave us vs what our perfect sort looks like", - " pm.expect(actualOrder).to.eql(expectedOrder);", - " }", - "});" - ], - "type": "text/javascript", - "packages": {}, - "requests": {} - } - } - ], - "request": { - "method": "POST", - "header": [ - { - "key": "Content-Type", - "value": "application/json" - } - ], - "body": { - "mode": "raw", - "raw": "{\n \"assetPath\": \"//{{testSiteName}}/\",\n \"filters\": {\n \"text\": \"Test\"\n },\n \"sortBy\": \"title:asc\",\n \"contentCursor\": 0,\n \"maxResults\": 3\n}", - "options": { - "raw": { - "language": "json" - } - } - }, - "url": { - "raw": "{{serverURL}}/api/v1/drive/search", - "host": [ - "{{serverURL}}" - ], - "path": [ - "api", - "v1", - "drive", - "search" - ] - } - }, - "response": [] - }, - { - "name": "Deep Folder Navigation with Search", - "event": [ - { - "listen": "test", - "script": { - "exec": [ - "pm.test(\"Status code should be 200\", function () {", - " pm.response.to.have.status(200);", - "});", - "", - "pm.test(\"Deep folder search works\", function () {", - " var jsonData = pm.response.json();", - " var list = jsonData.entity.list;", - " ", - " // Should return results from within the drive-test-folder", - " pm.expect(list.length).to.be.at.least(0); // May be empty folder", - " ", - " // Response structure should be maintained", - " pm.expect(jsonData.entity).to.have.property('folderCount');", - " pm.expect(jsonData.entity).to.have.property('contentCount');", - "});", - "", - "pm.test(\"Folder-specific results\", function () {", - " var jsonData = pm.response.json();", - " // Should show subfolders like alpha-subfolder, beta-subfolder", - " var folderCount = jsonData.entity.folderCount;", - " pm.expect(folderCount).to.be.at.least(0);", - "});" - ], - "type": "text/javascript" - } - } - ], - "request": { - "method": "POST", - "header": [ - { - "key": "Content-Type", - "value": "application/json" - } - ], - "body": { - "mode": "raw", - "raw": "{\n \"assetPath\": \"//{{testSiteName}}/drive-test-folder/\",\n \"filters\": {\n \"text\": \"subfolder\"\n },\n \"maxResults\": 10\n}", - "options": { - "raw": { - "language": "json" - } - } - }, - "url": { - "raw": "{{serverURL}}/api/v1/drive/search", - "host": [ - "{{serverURL}}" - ], - "path": [ - "api", - "v1", - "drive", - "search" - ] - } - }, - "response": [] - } - ], - "description": "Tests combining search, sorting, and pagination features together." - }, - { - "name": "Menu Links Tests", - "item": [ - { - "name": "showLinks Omitted - No Links And Zeroed Link Metadata", - "event": [ - { - "listen": "test", - "script": { - "exec": [ - "pm.test(\"Status code should be 200\", function () {", - " pm.response.to.have.status(200);", - "});", - "", - "var entity = pm.response.json().entity;", - "", - "pm.test(\"Link metadata is present and zeroed when showLinks is not sent\", function () {", - " pm.expect(entity).to.have.property(\"linkCount\", 0);", - " pm.expect(entity).to.have.property(\"hasMoreLinks\", false);", - " pm.expect(entity).to.have.property(\"nextLinkCursor\", 0);", - "});", - "", - "pm.test(\"No menu link leaks into a request that never asked for one\", function () {", - " var links = entity.list.filter(function (item) {", - " return item.mimeType === \"application/dotlink\";", - " });", - " pm.expect(links).to.be.an(\"array\").that.is.empty;", - "});" - ], - "type": "text/javascript" - } - } - ], - "request": { - "method": "POST", - "header": [ - { - "key": "Content-Type", - "value": "application/json" - } - ], - "body": { - "mode": "raw", - "raw": "{\n \"assetPath\": \"//{{testSiteName}}/\",\n \"maxResults\": 50\n}", - "options": { - "raw": { - "language": "json" - } - } - }, - "url": { - "raw": "{{serverURL}}/api/v1/drive/search", - "host": [ - "{{serverURL}}" - ], - "path": [ - "api", - "v1", - "drive", - "search" - ] - } - }, - "response": [] - }, - { - "name": "showLinks True - Response Carries Link Pagination Contract", - "event": [ - { - "listen": "test", - "script": { - "exec": [ - "pm.test(\"Status code should be 200\", function () {", - " pm.response.to.have.status(200);", - "});", - "", - "var entity = pm.response.json().entity;", - "", - "pm.test(\"Links are a third symmetric pagination source\", function () {", - " pm.expect(entity).to.have.property(\"linkCount\");", - " pm.expect(entity).to.have.property(\"hasMoreLinks\");", - " pm.expect(entity).to.have.property(\"nextLinkCursor\");", - " pm.expect(entity.linkCount).to.be.a(\"number\");", - " pm.expect(entity.hasMoreLinks).to.be.a(\"boolean\");", - " pm.expect(entity.nextLinkCursor).to.be.a(\"number\");", - "});", - "", - "pm.test(\"Folder and content metadata is unaffected\", function () {", - " pm.expect(entity).to.have.property(\"folderCount\");", - " pm.expect(entity).to.have.property(\"contentCount\");", - " pm.expect(entity).to.have.property(\"hasMoreFolders\");", - " pm.expect(entity).to.have.property(\"hasMoreContent\");", - "});", - "", - "// This site has no menu links, so linkCount is 0 here. Positive \"a link came back\"", - "// coverage lives in ContentDriveLinksTest -- the REST API exposes no endpoint that", - "// can create a menu Link, so Postman cannot build the fixture.", - "pm.test(\"linkCount agrees with the links actually in the list\", function () {", - " var links = entity.list.filter(function (item) {", - " return item.mimeType === \"application/dotlink\";", - " });", - " pm.expect(entity.linkCount).to.eql(links.length);", - "});" - ], - "type": "text/javascript" - } - } - ], - "request": { - "method": "POST", - "header": [ - { - "key": "Content-Type", - "value": "application/json" - } - ], - "body": { - "mode": "raw", - "raw": "{\n \"assetPath\": \"//{{testSiteName}}/\",\n \"showLinks\": true,\n \"maxResults\": 50\n}", - "options": { - "raw": { - "language": "json" - } - } - }, - "url": { - "raw": "{{serverURL}}/api/v1/drive/search", - "host": [ - "{{serverURL}}" - ], - "path": [ - "api", - "v1", - "drive", - "search" - ] - } - }, - "response": [] - }, - { - "name": "showLinks False - Behaves Like Omitting The Flag", - "event": [ - { - "listen": "test", - "script": { - "exec": [ - "pm.test(\"Status code should be 200\", function () {", - " pm.response.to.have.status(200);", - "});", - "", - "var entity = pm.response.json().entity;", - "", - "pm.test(\"Explicit false excludes links\", function () {", - " pm.expect(entity.linkCount).to.eql(0);", - " pm.expect(entity.hasMoreLinks).to.eql(false);", - "});" - ], - "type": "text/javascript" - } - } - ], - "request": { - "method": "POST", - "header": [ - { - "key": "Content-Type", - "value": "application/json" - } - ], - "body": { - "mode": "raw", - "raw": "{\n \"assetPath\": \"//{{testSiteName}}/\",\n \"showLinks\": false,\n \"maxResults\": 50\n}", - "options": { - "raw": { - "language": "json" - } - } - }, - "url": { - "raw": "{{serverURL}}/api/v1/drive/search", - "host": [ - "{{serverURL}}" - ], - "path": [ - "api", - "v1", - "drive", - "search" - ] - } - }, - "response": [] - }, - { - "name": "linkCursor - Accepted Alongside The Other Cursors", - "event": [ - { - "listen": "test", - "script": { - "exec": [ - "pm.test(\"Status code should be 200\", function () {", - " pm.response.to.have.status(200);", - "});", - "", - "var entity = pm.response.json().entity;", - "", - "pm.test(\"All three cursors round-trip\", function () {", - " pm.expect(entity.nextLinkCursor).to.be.at.least(0);", - " pm.expect(entity.nextFolderCursor).to.be.at.least(0);", - " pm.expect(entity.nextContentCursor).to.be.at.least(0);", - "});", - "", - "pm.test(\"maxResults is still respected\", function () {", - " pm.expect(entity.list.length).to.be.at.most(5);", - "});" - ], - "type": "text/javascript" - } - } - ], - "request": { - "method": "POST", - "header": [ - { - "key": "Content-Type", - "value": "application/json" - } - ], - "body": { - "mode": "raw", - "raw": "{\n \"assetPath\": \"//{{testSiteName}}/\",\n \"showLinks\": true,\n \"linkCursor\": 0,\n \"folderCursor\": 0,\n \"contentCursor\": 0,\n \"maxResults\": 5\n}", - "options": { - "raw": { - "language": "json" - } - } - }, - "url": { - "raw": "{{serverURL}}/api/v1/drive/search", - "host": [ - "{{serverURL}}" - ], - "path": [ - "api", - "v1", - "drive", - "search" - ] - } - }, - "response": [] - }, - { - "name": "showLinks With Empty baseTypes - Links Only Request", - "event": [ - { - "listen": "test", - "script": { - "exec": [ - "pm.test(\"Status code should be 200\", function () {", - " pm.response.to.have.status(200);", - "});", - "", - "var entity = pm.response.json().entity;", - "", - "// Links are not a BaseContentType, so an empty baseTypes array is the documented", - "// way to ask for links without content.", - "pm.test(\"Empty baseTypes disables folders and content\", function () {", - " pm.expect(entity.folderCount).to.eql(0);", - " pm.expect(entity.contentCount).to.eql(0);", - "});", - "", - "pm.test(\"Only links may appear in a links-only request\", function () {", - " var nonLinks = entity.list.filter(function (item) {", - " return item.mimeType !== \"application/dotlink\";", - " });", - " pm.expect(nonLinks).to.be.an(\"array\").that.is.empty;", - "});" - ], - "type": "text/javascript" - } - } - ], - "request": { - "method": "POST", - "header": [ - { - "key": "Content-Type", - "value": "application/json" - } - ], - "body": { - "mode": "raw", - "raw": "{\n \"assetPath\": \"//{{testSiteName}}/\",\n \"showLinks\": true,\n \"showFolders\": false,\n \"baseTypes\": [],\n \"maxResults\": 50\n}", - "options": { - "raw": { - "language": "json" - } - } - }, - "url": { - "raw": "{{serverURL}}/api/v1/drive/search", - "host": [ - "{{serverURL}}" - ], - "path": [ - "api", - "v1", - "drive", - "search" - ] - } - }, - "response": [] - }, - { - "name": "showLinks With mimeTypes - Links Suppressed", - "event": [ - { - "listen": "test", - "script": { - "exec": [ - "pm.test(\"Status code should be 200\", function () {", - " pm.response.to.have.status(200);", - "});", - "", - "var entity = pm.response.json().entity;", - "", - "// A Link carries no file MIME type, so it could never satisfy a mimeTypes filter.", - "pm.test(\"A mimeTypes filter drops links\", function () {", - " pm.expect(entity.linkCount).to.eql(0);", - " pm.expect(entity.hasMoreLinks).to.eql(false);", - "});", - "", - "pm.test(\"The mimeType filter itself still works\", function () {", - " entity.list.forEach(function (item) {", - " pm.expect(item.mimeType).to.not.eql(\"application/dotlink\");", - " });", - "});" - ], - "type": "text/javascript" - } - } - ], - "request": { - "method": "POST", - "header": [ - { - "key": "Content-Type", - "value": "application/json" - } - ], - "body": { - "mode": "raw", - "raw": "{\n \"assetPath\": \"//{{testSiteName}}/\",\n \"showLinks\": true,\n \"mimeTypes\": [\n \"image/jpeg\"\n ],\n \"maxResults\": 50\n}", - "options": { - "raw": { - "language": "json" - } - } - }, - "url": { - "raw": "{{serverURL}}/api/v1/drive/search", - "host": [ - "{{serverURL}}" - ], - "path": [ - "api", - "v1", - "drive", - "search" - ] - } - }, - "response": [] - }, - { - "name": "showLinks With filters.text - Filter Applied To Link Titles", - "event": [ - { - "listen": "test", - "script": { - "exec": [ - "pm.test(\"Status code should be 200\", function () {", - " pm.response.to.have.status(200);", - "});", - "", - "var entity = pm.response.json().entity;", - "var term = \"alpha\";", - "", - "pm.test(\"Link pagination contract survives filters.text\", function () {", - " pm.expect(entity).to.have.property(\"linkCount\");", - " pm.expect(entity).to.have.property(\"hasMoreLinks\");", - " pm.expect(entity).to.have.property(\"nextLinkCursor\");", - " pm.expect(entity.linkCount).to.be.at.least(0);", - "});", - "", - "var links = entity.list.filter(function (item) {", - " return item.mimeType === \"application/dotlink\";", - "});", - "", - "pm.test(\"linkCount agrees with the links in the page\", function () {", - " pm.expect(entity.linkCount).to.eql(links.length);", - "});", - "", - "pm.test(\"Every returned link title matches the filter term\", function () {", - " links.forEach(function (link) {", - " pm.expect(String(link.title).toLowerCase()).to.include(term);", - " });", - "});" - ], - "type": "text/javascript" - } - } - ], - "request": { - "method": "POST", - "header": [ - { - "key": "Content-Type", - "value": "application/json" - } - ], - "body": { - "mode": "raw", - "raw": "{\n \"assetPath\": \"//{{testSiteName}}/\",\n \"showLinks\": true,\n \"showFolders\": false,\n \"baseTypes\": [],\n \"filters\": {\n \"text\": \"alpha\"\n },\n \"maxResults\": 50\n}", - "options": { - "raw": { - "language": "json" - } - } - }, - "url": { - "raw": "{{serverURL}}/api/v1/drive/search", - "host": [ - "{{serverURL}}" - ], - "path": [ - "api", - "v1", - "drive", - "search" - ] - }, - "description": "showLinks combined with filters.text. Link titles are narrowed in memory because links are not indexed in Elasticsearch, and this combination had no coverage at all before -- not even that it returns 200 rather than erroring. Menu links cannot be created over REST, so the substantive assertions (only the matching link comes back, case-insensitively) live in the ContentDriveLinksTest integration test. What is pinned here is that the combination is accepted, that the link pagination contract survives it, and that any link returned really does match the term." - }, - "response": [] - }, - { - "name": "showLinks With live true - No Duplicate Links", - "event": [ - { - "listen": "test", - "script": { - "exec": [ - "pm.test(\"Status code should be 200\", function () {", - " pm.response.to.have.status(200);", - "});", - "", - "var entity = pm.response.json().entity;", - "var links = entity.list.filter(function (item) {", - " return item.mimeType === \"application/dotlink\";", - "});", - "", - "pm.test(\"live:true must never return the same link twice\", function () {", - " var ids = links.map(function (link) { return link.identifier; });", - " var unique = ids.filter(function (id, index) { return ids.indexOf(id) === index; });", - " pm.expect(unique.length, JSON.stringify(ids)).to.eql(ids.length);", - "});", - "", - "pm.test(\"linkCount agrees with the links in the page\", function () {", - " pm.expect(entity.linkCount).to.eql(links.length);", - "});" - ], - "type": "text/javascript" - } - } - ], - "request": { - "method": "POST", - "header": [ - { - "key": "Content-Type", - "value": "application/json" - } - ], - "body": { - "mode": "raw", - "raw": "{\n \"assetPath\": \"//{{testSiteName}}/\",\n \"showLinks\": true,\n \"showFolders\": false,\n \"baseTypes\": [],\n \"live\": true,\n \"archived\": false,\n \"maxResults\": 50\n}", - "options": { - "raw": { - "language": "json" - } - } - }, - "url": { - "raw": "{{serverURL}}/api/v1/drive/search", - "host": [ - "{{serverURL}}" - ], - "path": [ - "api", - "v1", - "drive", - "search" - ] - }, - "description": "Regression guard for showLinks + live:true -- the combination the redirect custom field sends as showWorking:false. Asking FolderFactoryImpl for working=false left the version-table predicate uncorrelated, so the query degenerated into a cross product and could return the same link many times. BrowserAPIImpl.getLinks now always asks for the working links and resolves 'live' by keeping the ones that carry a published version." - }, - "response": [] - } - ], - "description": "Coverage for the showLinks flag and the link pagination fields on POST /api/v1/drive/search (issue #36991)." - }, - { - "name": "Cleanup", - "item": [ - { - "name": "Delete Test Content Type", - "event": [ - { - "listen": "test", - "script": { - "exec": [ - "pm.test(\"Status code should be 200\", function () {", - " pm.response.to.have.status(200);", - "});", - "", - "pm.test(\"Content type deleted\", function () {", - " var jsonData = pm.response.json();", - " pm.expect(jsonData.errors.length).to.eql(0);", - "});" - ], - "type": "text/javascript" - } - } - ], - "request": { - "method": "DELETE", - "header": [], - "url": { - "raw": "{{serverURL}}/api/v1/contenttype/id/{{testContentTypeId}}", - "host": [ - "{{serverURL}}" - ], - "path": [ - "api", - "v1", - "contenttype", - "id", - "{{testContentTypeId}}" - ] - } - }, - "response": [] - }, - { - "name": "Archive Test Site", - "event": [ - { - "listen": "test", - "script": { - "exec": [ - "pm.test(\"Status code should be 200\", function () {", - " pm.response.to.have.status(200);", - "});", - "", - "pm.test(\"Site archived successfully\", function () {", - " var jsonData = pm.response.json();", - " pm.expect(jsonData.errors.length).to.eql(0);", - "});" - ], - "type": "text/javascript", - "packages": {}, - "requests": {} - } - } - ], - "request": { - "method": "PUT", - "header": [ - { - "key": "Content-Type", - "value": "application/json" - } - ], - "body": { - "mode": "raw", - "raw": "{\n \"archived\": true\n}", - "options": { - "raw": { - "language": "json" - } - } - }, - "url": { - "raw": "{{serverURL}}/api/v1/site/{{testSiteId}}/_archive", - "host": [ - "{{serverURL}}" - ], - "path": [ - "api", - "v1", - "site", - "{{testSiteId}}", - "_archive" - ] - } - }, - "response": [] - }, - { - "name": "Delete Test Site", - "event": [ - { - "listen": "test", - "script": { - "exec": [ - "pm.test(\"Status code should be 200\", function () {", - " pm.response.to.have.status(200);", - "});", - "", - "pm.test(\"Site archived successfully\", function () {", - " var jsonData = pm.response.json();", - " pm.expect(jsonData.errors.length).to.eql(0);", - "});" - ], - "type": "text/javascript", - "packages": {}, - "requests": {} - } - } - ], - "request": { - "method": "DELETE", - "header": [ - { - "key": "Content-Type", - "value": "application/json" - } - ], - "body": { - "mode": "raw", - "raw": "{\n \"archived\": true\n}", - "options": { - "raw": { - "language": "json" - } - } - }, - "url": { - "raw": "{{serverURL}}/api/v1/site/{{testSiteId}}", - "host": [ - "{{serverURL}}" - ], - "path": [ - "api", - "v1", - "site", - "{{testSiteId}}" - ] - } - }, - "response": [] - } - ], - "description": "Cleanup test data created during the test run." - } - ], - "auth": { - "type": "bearer", - "bearer": [ - { - "key": "token", - "value": "{{jwt}}", - "type": "string" - } - ] - }, - "event": [ - { - "listen": "prerequest", - "script": { - "type": "text/javascript", - "packages": {}, - "requests": {}, - "exec": [ - "sleep = function(milliseconds) {", - " const start = Date.now();", - " while (Date.now() - start < milliseconds) {}", - " console.log(`⏱️ Delayed ${milliseconds}ms`);", - "};", - "", - "sleep(3000);", - "", - "if (!pm.environment.get('jwt')) {", - " console.log(\"generating....\")", - " const serverURL = pm.environment.get('serverURL'); // Get the server URL from the environment variable", - " const apiUrl = `${serverURL}/api/v1/apitoken`; // Construct the full API URL", - "", - " if (!pm.environment.get('jwt')) {", - " const username = pm.environment.get(\"user\");", - " const password = pm.environment.get(\"password\");", - " const basicAuth = Buffer.from(`${username}:${password}`).toString('base64');", - "", - " const requestOptions = {", - " url: apiUrl,", - " method: \"POST\",", - " header: {", - " \"accept\": \"*/*\",", - " \"content-type\": \"application/json\",", - " \"Authorization\": `Basic ${basicAuth}`", - " },", - " body: {", - " mode: \"raw\",", - " raw: JSON.stringify({", - " \"expirationSeconds\": 7200,", - " \"userId\": \"dotcms.org.1\",", - " \"network\": \"0.0.0.0/0\",", - " \"claims\": {\"label\": \"postman-tests\"}", - " })", - " }", - " };", - "", - " pm.sendRequest(requestOptions, function (err, response) {", - " if (err) {", - " console.log(err);", - " } else {", - " const jwt = response.json().entity.jwt;", - " pm.environment.set('jwt', jwt);", - " console.log(jwt);", - " }", - " });", - " }", - "}", - "" - ] - } - }, - { - "listen": "test", - "script": { - "type": "text/javascript", - "packages": {}, - "requests": {}, - "exec": [ - "" - ] - } - } - ] -} \ No newline at end of file + "info": { + "_postman_id": "7d861cdb-18a9-4c48-9df0-a7d1a99406c6", + "name": "Content Drive", + "description": "Comprehensive tests for Content Drive search functionality including pagination, sorting, and filtering tests.", + "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json", + "_exporter_id": "5403727" + }, + "item": [ + { + "name": "Test Data Setup", + "item": [ + { + "name": "Create Test Site", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Status code should be 200\", function () {", + " pm.response.to.have.status(200);", + "});", + "", + "var jsonData = pm.response.json();", + "pm.collectionVariables.set(\"testSiteId\", jsonData.entity.identifier);", + "pm.collectionVariables.set(\"testSiteName\", jsonData.entity.siteName);", + "", + "pm.test(\"Site created successfully\", function () {", + " pm.expect(jsonData.entity.siteName).to.eql('contentdrive.test.site');", + " pm.expect(jsonData.entity.identifier).to.not.be.empty;", + "});" + ], + "type": "text/javascript", + "packages": {}, + "requests": {} + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"siteName\": \"contentdrive.test.site\"\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{serverURL}}/api/v1/site", + "host": [ + "{{serverURL}}" + ], + "path": [ + "api", + "v1", + "site" + ] + } + }, + "response": [] + }, + { + "name": "Create Test Content Type for Drive Search", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Status code should be 200\", function () {", + " pm.response.to.have.status(200);", + "});", + "", + "var jsonData = pm.response.json();", + "pm.collectionVariables.set(\"testContentTypeId\", jsonData.entity[0].id);", + "pm.collectionVariables.set(\"testContentTypeVar\", jsonData.entity[0].variable);", + "", + "pm.test(\"Content type created with title field\", function () {", + " pm.expect(jsonData.entity[0].variable).to.include('driveSearchTest');", + " pm.expect(jsonData.entity[0].fields).to.have.length.at.least(1);", + " // Check for title field", + " var titleField = jsonData.entity[0].fields.find(f => f.variable === 'title');", + " pm.expect(titleField).to.not.be.undefined;", + "});" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"clazz\": \"com.dotcms.contenttype.model.type.SimpleContentType\",\n \"description\": \"Content Type for Drive Search Tests\",\n \"defaultType\": false,\n \"system\": false,\n \"folder\": \"SYSTEM_FOLDER\",\n \"name\": \"Drive Search Test {{$randomBankAccount}}\",\n \"variable\": \"driveSearchTest{{$randomBankAccount}}\",\n \"host\": \"SYSTEM_HOST\",\n \"fixed\": false,\n \"fields\": [\n {\n \"clazz\": \"com.dotcms.contenttype.model.field.TextField\",\n \"indexed\": true,\n \"dataType\": \"TEXT\",\n \"readOnly\": false,\n \"required\": true,\n \"searchable\": true,\n \"listed\": true,\n \"sortOrder\": 1,\n \"unique\": false,\n \"name\": \"Title\",\n \"variable\": \"title\",\n \"fixed\": true\n },\n {\n \"clazz\": \"com.dotcms.contenttype.model.field.TextAreaField\",\n \"indexed\": true,\n \"dataType\": \"LONG_TEXT\",\n \"readOnly\": false,\n \"required\": false,\n \"searchable\": true,\n \"listed\": false,\n \"sortOrder\": 2,\n \"unique\": false,\n \"name\": \"Body\",\n \"variable\": \"body\"\n }\n ],\n \"workflow\": [\"d61a59e1-a49c-46f2-a929-db2b4bfa88b2\"]\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{serverURL}}/api/v1/contenttype", + "host": [ + "{{serverURL}}" + ], + "path": [ + "api", + "v1", + "contenttype" + ] + } + }, + "response": [] + }, + { + "name": "Create Test Folders", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Status code should be 200\", function () {", + " pm.response.to.have.status(200);", + "});", + "", + "var jsonData = pm.response.json();", + "pm.collectionVariables.set(\"testFolderId\", jsonData.entity.identifier);", + "", + "pm.test(\"Test folder created\", function () {", + " pm.expect(jsonData.entity[0].name).to.eql('drive-test-folder');", + "});" + ], + "type": "text/javascript", + "packages": {}, + "requests": {} + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "\n [\"/drive-test-folder/\"]\n", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{serverURL}}/api/v1/folder/createfolders/{{testSiteName}}", + "host": [ + "{{serverURL}}" + ], + "path": [ + "api", + "v1", + "folder", + "createfolders", + "{{testSiteName}}" + ] + } + }, + "response": [] + }, + { + "name": "Create Test Contentlets - Alpha Items", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Status code should be 200\", function () {", + " pm.response.to.have.status(200);", + "});", + "", + "var jsonData = pm.response.json();", + "pm.test(\"Alpha contentlets created\", function () {", + " pm.expect(jsonData.errors.length).to.eql(0);", + " pm.expect(jsonData.entity.results.length).to.eql(3);", + " ", + " // Store contentlet identifiers for cleanup", + " jsonData.entity.results.forEach((result, index) => {", + " var contentId = Object.keys(result)[0];", + " pm.collectionVariables.set(`alphaContentlet${index+1}Id`, result[contentId].identifier);", + " });", + "});" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"contentlets\": [\n {\n \"contentType\": \"{{testContentTypeVar}}\",\n \"title\": \"Alpha Test Content\",\n \"contentHost\": \"{{testSiteId}}\",\n \"body\": \"This is alpha test content for drive search\"\n },\n {\n \"contentType\": \"{{testContentTypeVar}}\",\n \"title\": \"Apple Product Review\",\n \"contentHost\": \"{{testSiteId}}\",\n \"body\": \"Detailed review of Apple products\"\n },\n {\n \"contentType\": \"{{testContentTypeVar}}\",\n \"title\": \"Analytics Dashboard Guide\",\n \"contentHost\": \"{{testSiteId}}\",\n \"body\": \"How to use analytics dashboard effectively\"\n }\n ]\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{serverURL}}/api/v1/workflow/actions/default/fire/PUBLISH", + "host": [ + "{{serverURL}}" + ], + "path": [ + "api", + "v1", + "workflow", + "actions", + "default", + "fire", + "PUBLISH" + ] + } + }, + "response": [] + }, + { + "name": "Create Test Contentlets - Beta Items", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Status code should be 200\", function () {", + " pm.response.to.have.status(200);", + "});", + "", + "var jsonData = pm.response.json();", + "pm.test(\"Beta contentlets created\", function () {", + " pm.expect(jsonData.errors.length).to.eql(0);", + " pm.expect(jsonData.entity.results.length).to.eql(3);", + " ", + " // Store contentlet identifiers for cleanup", + " jsonData.entity.results.forEach((result, index) => {", + " var contentId = Object.keys(result)[0];", + " pm.collectionVariables.set(`betaContentlet${index+1}Id`, result[contentId].identifier);", + " });", + "});" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"contentlets\": [\n {\n \"contentType\": \"{{testContentTypeVar}}\",\n \"title\": \"Beta Testing Framework\",\n \"contentHost\": \"{{testSiteId}}\",\n \"body\": \"Comprehensive beta testing framework documentation\"\n },\n {\n \"contentType\": \"{{testContentTypeVar}}\",\n \"title\": \"Business Intelligence Report\",\n \"contentHost\": \"{{testSiteId}}\",\n \"body\": \"Monthly business intelligence report\"\n },\n {\n \"contentType\": \"{{testContentTypeVar}}\",\n \"title\": \"Best Practices Guide\",\n \"contentHost\": \"{{testSiteId}}\",\n \"body\": \"Development best practices and guidelines\"\n }\n ]\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{serverURL}}/api/v1/workflow/actions/default/fire/PUBLISH", + "host": [ + "{{serverURL}}" + ], + "path": [ + "api", + "v1", + "workflow", + "actions", + "default", + "fire", + "PUBLISH" + ] + } + }, + "response": [] + }, + { + "name": "Create Additional Test Folders", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Status code should be 200\", function () {", + " pm.response.to.have.status(200);", + "});", + "", + "var jsonData = pm.response.json();", + "pm.test(\"Additional folders created\", function () {", + " pm.expect(jsonData.entity[0].name).to.include('subfolder');", + "});" + ], + "type": "text/javascript", + "packages": {}, + "requests": {} + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "[\"/drive-test-folder/alpha-subfolder/\"]", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{serverURL}}/api/v1/folder/createfolders/{{testSiteName}}", + "host": [ + "{{serverURL}}" + ], + "path": [ + "api", + "v1", + "folder", + "createfolders", + "{{testSiteName}}" + ] + } + }, + "response": [] + }, + { + "name": "Create Beta Subfolder", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Status code should be 200\", function () {", + " pm.response.to.have.status(200);", + "});", + "", + "var jsonData = pm.response.json();", + "pm.test(\"Beta subfolder created\", function () {", + " pm.expect(jsonData.entity[0].name).to.include('beta-subfolder');", + "});" + ], + "type": "text/javascript", + "packages": {}, + "requests": {} + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": " [\"/drive-test-folder/beta-subfolder/\"]", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{serverURL}}/api/v1/folder/createfolders/{{testSiteName}}", + "host": [ + "{{serverURL}}" + ], + "path": [ + "api", + "v1", + "folder", + "createfolders", + "{{testSiteName}}" + ] + } + }, + "response": [] + }, + { + "name": "Create Image File", + "request": { + "method": "PUT", + "header": [], + "body": { + "mode": "formdata", + "formdata": [ + { + "key": "file", + "type": "file", + "src": "resources/image/Landscape_2008_urban_park_and_plaza_Ankaran.jpeg" + }, + { + "key": "json", + "value": "{\n \"contentlet\": {\n \"contentType\":\"FileAsset\",\n \"title\":\"Test Image\",\n \"hostFolder\":\"{{testSiteName}}\"\n }\n}", + "type": "text" + }, + { + "key": "", + "value": "", + "type": "text", + "disabled": true + } + ] + }, + "url": { + "raw": "{{serverURL}}/api/v1/workflow/actions/default/fire/PUBLISH?language=1", + "host": [ + "{{serverURL}}" + ], + "path": [ + "api", + "v1", + "workflow", + "actions", + "default", + "fire", + "PUBLISH" + ], + "query": [ + { + "key": "language", + "value": "1" + } + ] + } + }, + "response": [] + } + ], + "description": "Sets up test data including sites, content types, folders, and contentlets for drive search testing." + }, + { + "name": "Pagination Tests", + "item": [ + { + "name": "Basic Pagination - First Page", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Status code should be 200\", function () {", + " pm.response.to.have.status(200);", + "});", + "", + "pm.test(\"Response should have pagination structure\", function () {", + " var jsonData = pm.response.json();", + " pm.expect(jsonData).to.have.property('entity');", + " pm.expect(jsonData.entity).to.have.property('list');", + " pm.expect(jsonData.entity).to.have.property('folderCount');", + " pm.expect(jsonData.entity).to.have.property('contentCount');", + "});", + "", + "pm.test(\"First page results within limit\", function () {", + " var jsonData = pm.response.json();", + " var list = jsonData.entity.list;", + " pm.expect(list.length).to.be.at.most(5); // maxResults limit", + " pm.expect(list.length).to.be.at.least(1);", + "});", + "", + "pm.test(\"Contains both folders and contentlets\", function () {", + " var jsonData = pm.response.json();", + " var folderCount = jsonData.entity.folderCount;", + " var contentCount = jsonData.entity.contentCount;", + " pm.expect(folderCount + contentCount).to.be.at.least(1);", + "});" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"assetPath\": \"//{{testSiteName}}/\",\n \"contentCursor\": 0,\n \"maxResults\": 5\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{serverURL}}/api/v1/drive/search", + "host": [ + "{{serverURL}}" + ], + "path": [ + "api", + "v1", + "drive", + "search" + ] + } + }, + "response": [] + }, + { + "name": "Pagination - Second Page", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Status code should be 200\", function () {", + " pm.response.to.have.status(200);", + "});", + "", + "pm.test(\"Second page contentCursor handled correctly\", function () {", + " var jsonData = pm.response.json();", + " var list = jsonData.entity.list;", + " // Should have remaining results or be empty if total <= 5", + " pm.expect(list.length).to.be.at.most(5);", + "});", + "", + "pm.test(\"Pagination metadata consistent\", function () {", + " var jsonData = pm.response.json();", + " pm.expect(jsonData.entity.folderCount).to.be.a('number');", + " pm.expect(jsonData.entity.contentCount).to.be.a('number');", + "});" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"assetPath\": \"//{{testSiteName}}/\",\n \"contentCursor\": 5,\n \"maxResults\": 5\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{serverURL}}/api/v1/drive/search", + "host": [ + "{{serverURL}}" + ], + "path": [ + "api", + "v1", + "drive", + "search" + ] + } + }, + "response": [] + }, + { + "name": "Large MaxResults Test", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Status code should be 200\", function () {", + " pm.response.to.have.status(200);", + "});", + "", + "pm.test(\"Large maxResults respected\", function () {", + " var jsonData = pm.response.json();", + " var list = jsonData.entity.list;", + " pm.expect(list.length).to.be.at.most(50); // Should not exceed total available", + "});", + "", + "pm.test(\"All items returned when maxResults is large\", function () {", + " var jsonData = pm.response.json();", + " var totalExpected = jsonData.entity.folderCount + jsonData.entity.contentCount;", + " pm.expect(jsonData.entity.list.length).to.be.at.most(totalExpected);", + "});" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"assetPath\": \"//{{testSiteName}}/\",\n \"contentCursor\": 0,\n \"maxResults\": 50\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{serverURL}}/api/v1/drive/search", + "host": [ + "{{serverURL}}" + ], + "path": [ + "api", + "v1", + "drive", + "search" + ] + } + }, + "response": [] + } + ], + "description": "Tests pagination functionality with different contentCursor and maxResults values." + }, + { + "name": "Sorting Tests", + "item": [ + { + "name": "Sort by ModDate (Default)", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Status code should be 200\", function () {", + " pm.response.to.have.status(200);", + "});", + "", + "pm.test(\"Results are sorted by modification date\", function () {", + " var jsonData = pm.response.json();", + " var list = jsonData.entity.list;", + " ", + " if (list.length > 1) {", + " for (let i = 0; i < list.length - 1; i++) {", + " if (list[i].modDate && list[i+1].modDate) {", + " var date1 = new Date(list[i].modDate);", + " var date2 = new Date(list[i+1].modDate);", + " // date1 should be LESS THAN OR EQUAL to date2 (ascending)", + " pm.expect(date1.getTime()).to.be.at.most(date2.getTime());", + " }", + " }", + " }", + "});", + "", + "pm.test(\"Mixed content types present\", function () {", + " var jsonData = pm.response.json();", + " var folderCount = jsonData.entity.folderCount;", + " var contentCount = jsonData.entity.contentCount;", + " pm.expect(folderCount + contentCount).to.be.at.least(1);", + "});" + ], + "type": "text/javascript", + "packages": {}, + "requests": {} + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"assetPath\": \"//{{testSiteName}}/\",\n \"sortBy\": \"modDate\",\n \"showFolders\": false,\n \"maxResults\": 10\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{serverURL}}/api/v1/drive/search", + "host": [ + "{{serverURL}}" + ], + "path": [ + "api", + "v1", + "drive", + "search" + ] + } + }, + "response": [] + }, + { + "name": "Sort by Title Ascending", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Status code should be 200\", function () {", + " pm.response.to.have.status(200);", + "});", + "", + "pm.test(\"Results sorted by title ascending\", function () {", + " var jsonData = pm.response.json();", + " var list = jsonData.entity.list;", + " ", + " if (list.length > 1) {", + " // Extract contentlets with titles for sorting verification", + " var contentletsWithTitles = list.filter(item => item.title || item.name);", + " ", + " if (contentletsWithTitles.length > 1) {", + " for (let i = 0; i < contentletsWithTitles.length - 1; i++) {", + " var title1 = (contentletsWithTitles[i].title || contentletsWithTitles[i].name).toLowerCase();", + " var title2 = (contentletsWithTitles[i+1].title || contentletsWithTitles[i+1].name).toLowerCase();", + " pm.expect(title1.localeCompare(title2)).to.be.at.most(0);", + " }", + " }", + " }", + "});" + ], + "type": "text/javascript", + "packages": {}, + "requests": {} + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"assetPath\": \"//{{testSiteName}}/\",\n \"sortBy\": \"title:asc\",\n \"showFolders\": false, \n \"maxResults\": 10\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{serverURL}}/api/v1/drive/search", + "host": [ + "{{serverURL}}" + ], + "path": [ + "api", + "v1", + "drive", + "search" + ] + } + }, + "response": [] + }, + { + "name": "Sort by Title Descending", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Status code should be 200\", function () {", + " pm.response.to.have.status(200);", + "});", + "", + "pm.test(\"Results sorted by title descending\", function () {", + " var jsonData = pm.response.json();", + " var list = jsonData.entity.list;", + " ", + " if (list.length > 1) {", + " // Extract contentlets with titles for sorting verification", + " var contentletsWithTitles = list.filter(item => item.title || item.name);", + " ", + " if (contentletsWithTitles.length > 1) {", + " for (let i = 0; i < contentletsWithTitles.length - 1; i++) {", + " var title1 = (contentletsWithTitles[i].title || contentletsWithTitles[i].name).toLowerCase();", + " var title2 = (contentletsWithTitles[i+1].title || contentletsWithTitles[i+1].name).toLowerCase();", + " pm.expect(title1.localeCompare(title2)).to.be.at.least(0);", + " }", + " }", + " }", + "});" + ], + "type": "text/javascript", + "packages": {}, + "requests": {} + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"assetPath\": \"//{{testSiteName}}/\",\n \"sortBy\": \"title:desc\",\n \"showFolders\": false, \n \"maxResults\": 10\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{serverURL}}/api/v1/drive/search", + "host": [ + "{{serverURL}}" + ], + "path": [ + "api", + "v1", + "drive", + "search" + ] + } + }, + "response": [] + }, + { + "name": "Folder and Content Mixed Sorting", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Status code should be 200\", function () {", + " pm.response.to.have.status(200);", + "});", + "", + "pm.test(\"Folders and contentlets properly mixed in sort\", function () {", + " var jsonData = pm.response.json();", + " var list = jsonData.entity.list;", + " var folderCount = jsonData.entity.folderCount;", + " var contentCount = jsonData.entity.contentCount;", + " ", + " pm.expect(folderCount + contentCount).to.be.at.least(1);", + " ", + " // Verify that both folders and content can appear in the list", + " if (list.length > 0) {", + " var hasFolders = list.some(item => item.type === 'folder' || item.__icon__ === 'folder');", + " var hasContent = list.some(item => item.contentType || item.type === 'contentlet');", + " pm.expect(hasFolders || hasContent).to.be.true;", + " }", + "});", + "", + "pm.test(\"Results include test folder and test content\", function () {", + " var jsonData = pm.response.json();", + " var list = jsonData.entity.list;", + " ", + " // Look for our test items", + " var hasTestFolder = list.some(item => ", + " (item.name && item.name.includes('drive-test-folder')) ||", + " (item.title && item.title.includes('drive-test-folder'))", + " );", + " var hasTestContent = list.some(item => ", + " (item.title && (item.title.includes('Alpha') || item.title.includes('Beta'))) ||", + " (item.name && (item.name.includes('Alpha') || item.name.includes('Beta')))", + " );", + " ", + " // At least one should be present", + " pm.expect(hasTestFolder || hasTestContent).to.be.true;", + "});" + ], + "type": "text/javascript", + "packages": {}, + "requests": {} + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"assetPath\": \"//{{testSiteName}}/\",\n \"sortBy\": \"title:asc\",\n \"showFolders\": true, \n \"maxResults\": 20\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{serverURL}}/api/v1/drive/search", + "host": [ + "{{serverURL}}" + ], + "path": [ + "api", + "v1", + "drive", + "search" + ] + } + }, + "response": [] + } + ], + "description": "Tests various sorting options and their behavior with folders and contentlets." + }, + { + "name": "Search and Filtering Tests", + "item": [ + { + "name": "Text Search - Alpha Filter", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Status code should be 200\", function () {", + " pm.response.to.have.status(200);", + "});", + "", + "pm.test(\"Search results contain Alpha items\", function () {", + " var jsonData = pm.response.json();", + " var list = jsonData.entity.list;", + " ", + " pm.expect(list.length).to.be.at.least(1);", + " ", + " // Verify that results contain 'Alpha' in title or name", + " var hasAlphaItems = list.some(item => {", + " var title = item.title || item.name || '';", + " return title.toLowerCase().includes('alpha');", + " });", + " ", + " pm.expect(hasAlphaItems).to.be.true;", + "});", + "", + "pm.test(\"Search filters out non-matching items\", function () {", + " var jsonData = pm.response.json();", + " var list = jsonData.entity.list;", + " ", + " // Should not include items without 'Alpha' when filtering", + " var allItemsMatch = list.every(item => {", + " var title = item.title || item.name || '';", + " var body = item.body || '';", + " return title.toLowerCase().includes('alpha') || body.toLowerCase().includes('alpha');", + " });", + " ", + " // Note: Due to Elasticsearch behavior, this might not be 100% strict", + " // but most results should match", + " pm.expect(list.length).to.be.at.most(10); // Should be filtered down", + "});" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"assetPath\": \"//{{testSiteName}}/\",\n \"filters\": {\n \"text\": \"Alpha\"\n },\n \"maxResults\": 10\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{serverURL}}/api/v1/drive/search", + "host": [ + "{{serverURL}}" + ], + "path": [ + "api", + "v1", + "drive", + "search" + ] + } + }, + "response": [] + }, + { + "name": "Text Search - Beta Filter", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Status code should be 200\", function () {", + " pm.response.to.have.status(200);", + "});", + "", + "pm.test(\"Search results contain Beta items\", function () {", + " var jsonData = pm.response.json();", + " var list = jsonData.entity.list;", + " ", + " pm.expect(list.length).to.be.at.least(1);", + " ", + " // Verify that results contain 'Beta' in title or name", + " var hasBetaItems = list.some(item => {", + " var title = item.title || item.name || '';", + " return title.toLowerCase().includes('beta');", + " });", + " ", + " pm.expect(hasBetaItems).to.be.true;", + "});", + "", + "pm.test(\"Search with different terms returns different results\", function () {", + " var jsonData = pm.response.json();", + " var list = jsonData.entity.list;", + " ", + " // Store result count for comparison in collection variable", + " pm.collectionVariables.set('betaSearchResultCount', list.length);", + " ", + " pm.expect(list.length).to.be.at.most(10);", + "});" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"assetPath\": \"//{{testSiteName}}/\",\n \"filters\": {\n \"text\": \"Beta\"\n },\n \"maxResults\": 10\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{serverURL}}/api/v1/drive/search", + "host": [ + "{{serverURL}}" + ], + "path": [ + "api", + "v1", + "drive", + "search" + ] + } + }, + "response": [] + }, + { + "name": "Text Search - Partial Match", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Status code should be 200\", function () {", + " pm.response.to.have.status(200);", + "});", + "", + "pm.test(\"Partial search works (Test keyword)\", function () {", + " var jsonData = pm.response.json();", + " var list = jsonData.entity.list;", + " ", + " pm.expect(list.length).to.be.at.least(1);", + " ", + " // Verify that results contain 'Test' in title or name", + " var hasTestItems = list.some(item => {", + " var title = item.title || item.name || '';", + " return title.toLowerCase().includes('test');", + " });", + " ", + " pm.expect(hasTestItems).to.be.true;", + "});", + "", + "pm.test(\"Elasticsearch filtering active\", function () {", + " var jsonData = pm.response.json();", + " // When filtering is active, results should be focused", + " pm.expect(jsonData.entity.list.length).to.be.at.most(15);", + "});" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"assetPath\": \"//{{testSiteName}}/\",\n \"filters\": {\n \"text\": \"Test\"\n },\n \"maxResults\": 10\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{serverURL}}/api/v1/drive/search", + "host": [ + "{{serverURL}}" + ], + "path": [ + "api", + "v1", + "drive", + "search" + ] + } + }, + "response": [] + }, + { + "name": "Search with Empty Filter", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Status code should be 200\", function () {", + " pm.response.to.have.status(200);", + "});", + "", + "pm.test(\"Empty filter returns all results\", function () {", + " var jsonData = pm.response.json();", + " var list = jsonData.entity.list;", + " ", + " // Empty filter should return more results than specific searches", + " pm.expect(list.length).to.be.at.least(1);", + " ", + " // Should include both folders and contentlets", + " var folderCount = jsonData.entity.folderCount;", + " var contentCount = jsonData.entity.contentCount;", + " pm.expect(folderCount + contentCount).to.be.at.least(1);", + "});" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"assetPath\": \"//{{testSiteName}}/\",\n \"filters\": {\n \"text\": \"\"\n },\n \"maxResults\": 15\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{serverURL}}/api/v1/drive/search", + "host": [ + "{{serverURL}}" + ], + "path": [ + "api", + "v1", + "drive", + "search" + ] + } + }, + "response": [] + }, + { + "name": "Search No Results", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Status code should be 200\", function () {", + " pm.response.to.have.status(200);", + "});", + "", + "pm.test(\"Non-matching search returns minimal results\", function () {", + " var jsonData = pm.response.json();", + " var list = jsonData.entity.list;", + " ", + " // Should return very few or no results", + " pm.expect(list.length).to.be.at.most(2);", + "});", + "", + "pm.test(\"Response structure maintained even with no results\", function () {", + " var jsonData = pm.response.json();", + " pm.expect(jsonData.entity).to.have.property('list');", + " pm.expect(jsonData.entity).to.have.property('folderCount');", + " pm.expect(jsonData.entity).to.have.property('contentCount');", + "});" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"assetPath\": \"//{{testSiteName}}/\",\n \"filters\": {\n \"text\": \"NonExistentSearchTermThatShouldReturnNoResults12345\"\n },\n \"maxResults\": 10\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{serverURL}}/api/v1/drive/search", + "host": [ + "{{serverURL}}" + ], + "path": [ + "api", + "v1", + "drive", + "search" + ] + } + }, + "response": [] + }, + { + "name": "Search - MIME Type Filter", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Status code should be 200\", function () {", + " pm.response.to.have.status(200);", + "});", + "", + "pm.test(\"Verify results contain 'Test Image'\", function () {", + " const jsonData = pm.response.json();", + " const list = jsonData.entity.list;", + " pm.expect(list.length).to.be.at.least(1);", + " ", + " // Verify that results contain 'Test Image' in its title. This file asset ", + " // is 'Landscape_2008_urban_park_and_plaza_Ankaran.jpeg', created in the Test Data Setup", + " const hasTestImage = list.some(item => {", + " return item.title.toLowerCase().includes('test image');", + " });", + " ", + " pm.expect(hasTestImage).to.equal(true, \"Expected test image is not being returned\");", + "});", + "" + ], + "type": "text/javascript", + "packages": {}, + "requests": {} + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"assetPath\": \"//{{testSiteName}}/\",\n \"mimeTypes\": [\n \"image\"\n ],\n \"maxResults\": 10\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{serverURL}}/api/v1/drive/search", + "host": [ + "{{serverURL}}" + ], + "path": [ + "api", + "v1", + "drive", + "search" + ] + } + }, + "response": [] + } + ], + "description": "Tests search and filtering functionality using the filters.text parameter." + }, + { + "name": "Browse Scope Tests", + "item": [ + { + "name": "No Scope - Behaves As It Does Today", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "// The shape every caller outside Content Drive sends. Whatever it returns today it", + "// must keep returning: the Asset Picker and six other callers reach this same listing", + "// and none of them will ever send a scope.", + "pm.test(\"Status code should be 200\", function () {", + " pm.response.to.have.status(200);", + "});", + "", + "pm.test(\"Returns a listing, with no scope sent\", function () {", + " var body = pm.response.json();", + " pm.expect(body.entity).to.have.property('list');", + " pm.collectionVariables.set('noScopeCount', body.entity.list.length);", + "});" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"assetPath\": \"//{{testSiteName}}/\",\n \"maxResults\": 50\n}" + }, + "url": { + "raw": "{{serverURL}}/api/v1/drive/search", + "host": [ + "{{serverURL}}" + ], + "path": [ + "api", + "v1", + "drive", + "search" + ] + } + }, + "response": [] + }, + { + "name": "ALL At The Site Root - Same As Sending Nothing", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "// At the site root these are the same request. ALL is what the drive's own flat view", + "// asks for, and it must not mean anything different from the historic default.", + "pm.test(\"Status code should be 200\", function () {", + " pm.response.to.have.status(200);", + "});", + "", + "pm.test(\"Returns the same number of items as sending no scope\", function () {", + " var body = pm.response.json();", + " var noScope = Number(pm.collectionVariables.get('noScopeCount'));", + " pm.expect(body.entity.list.length).to.eql(noScope);", + "});" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"assetPath\": \"//{{testSiteName}}/\",\n \"browseScope\": \"ALL\",\n \"maxResults\": 50\n}" + }, + "url": { + "raw": "{{serverURL}}/api/v1/drive/search", + "host": [ + "{{serverURL}}" + ], + "path": [ + "api", + "v1", + "drive", + "search" + ] + } + }, + "response": [] + }, + { + "name": "ROOT - Lists The Site Root Only", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "// The view that did not exist before this feature: what sits AT the root, without the", + "// contents of the folders below it.", + "pm.test(\"Status code should be 200\", function () {", + " pm.response.to.have.status(200);", + "});", + "", + "pm.test(\"Admits nothing that lives inside a folder\", function () {", + " var body = pm.response.json();", + " body.entity.list.forEach(function (item) {", + " // Folders themselves belong at the root; their CONTENT does not.", + " if (item.baseType) {", + " pm.expect(item.folder, JSON.stringify(item.title)).to.eql('SYSTEM_FOLDER');", + " }", + " });", + "});" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"assetPath\": \"//{{testSiteName}}/\",\n \"browseScope\": \"ROOT\",\n \"maxResults\": 50\n}" + }, + "url": { + "raw": "{{serverURL}}/api/v1/drive/search", + "host": [ + "{{serverURL}}" + ], + "path": [ + "api", + "v1", + "drive", + "search" + ] + } + }, + "response": [] + }, + { + "name": "Scope With A Folder Path - Refused", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "// Refused rather than resolved by precedence. Picking one of two contradictory", + "// statements would list content from somewhere the caller did not ask for, and the", + "// caller would have no way to tell it happened.", + "pm.test(\"Status code should be 400\", function () {", + " pm.response.to.have.status(400);", + "});", + "", + "pm.test(\"Says which scope and which path it refused\", function () {", + " var text = pm.response.text();", + " pm.expect(text).to.include('browseScope');", + "});" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"assetPath\": \"//{{testSiteName}}/alpha-folder/\",\n \"browseScope\": \"ROOT\",\n \"maxResults\": 50\n}" + }, + "url": { + "raw": "{{serverURL}}/api/v1/drive/search", + "host": [ + "{{serverURL}}" + ], + "path": [ + "api", + "v1", + "drive", + "search" + ] + } + }, + "response": [] + } + ] + }, + { + "name": "Combined Tests", + "item": [ + { + "name": "Search with Sorting and Pagination", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Status code should be 200\", function () {", + " pm.response.to.have.status(200);", + "});", + "", + "pm.test(\"Combined search, sort and pagination works\", function () {", + " var jsonData = pm.response.json();", + " var list = jsonData.entity.list;", + " ", + " // Should respect maxResults", + " pm.expect(list.length).to.be.at.most(3);", + " ", + " // Should contain filtered results", + " if (list.length > 0) {", + " var hasFilteredItems = list.some(item => {", + " var title = item.title || item.name || '';", + " return title.toLowerCase().includes('test') || title.toLowerCase().includes('alpha') || title.toLowerCase().includes('beta');", + " });", + " pm.expect(hasFilteredItems).to.be.true;", + " }", + "});", + "", + "pm.test(\"Results are sorted: Folders first (by name), then Content (by title)\", function () {", + " var jsonData = pm.response.json();", + " var list = jsonData.entity.list;", + "", + " if (list && list.length > 1) {", + " // 1. Extract what we need into a clean, simplified array", + " var actualOrder = list.map(function(item) {", + " // Determine if it is a folder based on the \"type\" property", + " var isFolder = item.type === 'folder';", + " ", + " // Pick the correct string to sort by", + " var sortString = isFolder ? item.name : item.title;", + " ", + " return {", + " type: isFolder ? 'folder' : 'content',", + " text: (sortString || '').toString().toLowerCase()", + " };", + " });", + "", + " // 2. Create a copy of the actual order to sort ourselves", + " // We use JSON parse/stringify as a quick way to deep clone the array in Postman", + " var expectedOrder = JSON.parse(JSON.stringify(actualOrder));", + "", + " // 3. Apply your custom sorting rules to our expected array", + " expectedOrder.sort(function(a, b) {", + " // Rule A: Folders always come before Content", + " if (a.type === 'folder' && b.type === 'content') return -1;", + " if (a.type === 'content' && b.type === 'folder') return 1;", + "", + " // Rule B: If they are the SAME type, sort alphabetically", + " return a.text.localeCompare(b.text);", + " });", + "", + " // 4. Compare what the API gave us vs what our perfect sort looks like", + " pm.expect(actualOrder).to.eql(expectedOrder);", + " }", + "});" + ], + "type": "text/javascript", + "packages": {}, + "requests": {} + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"assetPath\": \"//{{testSiteName}}/\",\n \"filters\": {\n \"text\": \"Test\"\n },\n \"sortBy\": \"title:asc\",\n \"contentCursor\": 0,\n \"maxResults\": 3\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{serverURL}}/api/v1/drive/search", + "host": [ + "{{serverURL}}" + ], + "path": [ + "api", + "v1", + "drive", + "search" + ] + } + }, + "response": [] + }, + { + "name": "Deep Folder Navigation with Search", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Status code should be 200\", function () {", + " pm.response.to.have.status(200);", + "});", + "", + "pm.test(\"Deep folder search works\", function () {", + " var jsonData = pm.response.json();", + " var list = jsonData.entity.list;", + " ", + " // Should return results from within the drive-test-folder", + " pm.expect(list.length).to.be.at.least(0); // May be empty folder", + " ", + " // Response structure should be maintained", + " pm.expect(jsonData.entity).to.have.property('folderCount');", + " pm.expect(jsonData.entity).to.have.property('contentCount');", + "});", + "", + "pm.test(\"Folder-specific results\", function () {", + " var jsonData = pm.response.json();", + " // Should show subfolders like alpha-subfolder, beta-subfolder", + " var folderCount = jsonData.entity.folderCount;", + " pm.expect(folderCount).to.be.at.least(0);", + "});" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"assetPath\": \"//{{testSiteName}}/drive-test-folder/\",\n \"filters\": {\n \"text\": \"subfolder\"\n },\n \"maxResults\": 10\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{serverURL}}/api/v1/drive/search", + "host": [ + "{{serverURL}}" + ], + "path": [ + "api", + "v1", + "drive", + "search" + ] + } + }, + "response": [] + } + ], + "description": "Tests combining search, sorting, and pagination features together." + }, + { + "name": "Menu Links Tests", + "item": [ + { + "name": "showLinks Omitted - No Links And Zeroed Link Metadata", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Status code should be 200\", function () {", + " pm.response.to.have.status(200);", + "});", + "", + "var entity = pm.response.json().entity;", + "", + "pm.test(\"Link metadata is present and zeroed when showLinks is not sent\", function () {", + " pm.expect(entity).to.have.property(\"linkCount\", 0);", + " pm.expect(entity).to.have.property(\"hasMoreLinks\", false);", + " pm.expect(entity).to.have.property(\"nextLinkCursor\", 0);", + "});", + "", + "pm.test(\"No menu link leaks into a request that never asked for one\", function () {", + " var links = entity.list.filter(function (item) {", + " return item.mimeType === \"application/dotlink\";", + " });", + " pm.expect(links).to.be.an(\"array\").that.is.empty;", + "});" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"assetPath\": \"//{{testSiteName}}/\",\n \"maxResults\": 50\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{serverURL}}/api/v1/drive/search", + "host": [ + "{{serverURL}}" + ], + "path": [ + "api", + "v1", + "drive", + "search" + ] + } + }, + "response": [] + }, + { + "name": "showLinks True - Response Carries Link Pagination Contract", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Status code should be 200\", function () {", + " pm.response.to.have.status(200);", + "});", + "", + "var entity = pm.response.json().entity;", + "", + "pm.test(\"Links are a third symmetric pagination source\", function () {", + " pm.expect(entity).to.have.property(\"linkCount\");", + " pm.expect(entity).to.have.property(\"hasMoreLinks\");", + " pm.expect(entity).to.have.property(\"nextLinkCursor\");", + " pm.expect(entity.linkCount).to.be.a(\"number\");", + " pm.expect(entity.hasMoreLinks).to.be.a(\"boolean\");", + " pm.expect(entity.nextLinkCursor).to.be.a(\"number\");", + "});", + "", + "pm.test(\"Folder and content metadata is unaffected\", function () {", + " pm.expect(entity).to.have.property(\"folderCount\");", + " pm.expect(entity).to.have.property(\"contentCount\");", + " pm.expect(entity).to.have.property(\"hasMoreFolders\");", + " pm.expect(entity).to.have.property(\"hasMoreContent\");", + "});", + "", + "// This site has no menu links, so linkCount is 0 here. Positive \"a link came back\"", + "// coverage lives in ContentDriveLinksTest -- the REST API exposes no endpoint that", + "// can create a menu Link, so Postman cannot build the fixture.", + "pm.test(\"linkCount agrees with the links actually in the list\", function () {", + " var links = entity.list.filter(function (item) {", + " return item.mimeType === \"application/dotlink\";", + " });", + " pm.expect(entity.linkCount).to.eql(links.length);", + "});" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"assetPath\": \"//{{testSiteName}}/\",\n \"showLinks\": true,\n \"maxResults\": 50\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{serverURL}}/api/v1/drive/search", + "host": [ + "{{serverURL}}" + ], + "path": [ + "api", + "v1", + "drive", + "search" + ] + } + }, + "response": [] + }, + { + "name": "showLinks False - Behaves Like Omitting The Flag", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Status code should be 200\", function () {", + " pm.response.to.have.status(200);", + "});", + "", + "var entity = pm.response.json().entity;", + "", + "pm.test(\"Explicit false excludes links\", function () {", + " pm.expect(entity.linkCount).to.eql(0);", + " pm.expect(entity.hasMoreLinks).to.eql(false);", + "});" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"assetPath\": \"//{{testSiteName}}/\",\n \"showLinks\": false,\n \"maxResults\": 50\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{serverURL}}/api/v1/drive/search", + "host": [ + "{{serverURL}}" + ], + "path": [ + "api", + "v1", + "drive", + "search" + ] + } + }, + "response": [] + }, + { + "name": "linkCursor - Accepted Alongside The Other Cursors", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Status code should be 200\", function () {", + " pm.response.to.have.status(200);", + "});", + "", + "var entity = pm.response.json().entity;", + "", + "pm.test(\"All three cursors round-trip\", function () {", + " pm.expect(entity.nextLinkCursor).to.be.at.least(0);", + " pm.expect(entity.nextFolderCursor).to.be.at.least(0);", + " pm.expect(entity.nextContentCursor).to.be.at.least(0);", + "});", + "", + "pm.test(\"maxResults is still respected\", function () {", + " pm.expect(entity.list.length).to.be.at.most(5);", + "});" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"assetPath\": \"//{{testSiteName}}/\",\n \"showLinks\": true,\n \"linkCursor\": 0,\n \"folderCursor\": 0,\n \"contentCursor\": 0,\n \"maxResults\": 5\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{serverURL}}/api/v1/drive/search", + "host": [ + "{{serverURL}}" + ], + "path": [ + "api", + "v1", + "drive", + "search" + ] + } + }, + "response": [] + }, + { + "name": "showLinks With Empty baseTypes - Links Only Request", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Status code should be 200\", function () {", + " pm.response.to.have.status(200);", + "});", + "", + "var entity = pm.response.json().entity;", + "", + "// Links are not a BaseContentType, so an empty baseTypes array is the documented", + "// way to ask for links without content.", + "pm.test(\"Empty baseTypes disables folders and content\", function () {", + " pm.expect(entity.folderCount).to.eql(0);", + " pm.expect(entity.contentCount).to.eql(0);", + "});", + "", + "pm.test(\"Only links may appear in a links-only request\", function () {", + " var nonLinks = entity.list.filter(function (item) {", + " return item.mimeType !== \"application/dotlink\";", + " });", + " pm.expect(nonLinks).to.be.an(\"array\").that.is.empty;", + "});" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"assetPath\": \"//{{testSiteName}}/\",\n \"showLinks\": true,\n \"showFolders\": false,\n \"baseTypes\": [],\n \"maxResults\": 50\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{serverURL}}/api/v1/drive/search", + "host": [ + "{{serverURL}}" + ], + "path": [ + "api", + "v1", + "drive", + "search" + ] + } + }, + "response": [] + }, + { + "name": "showLinks With mimeTypes - Links Suppressed", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Status code should be 200\", function () {", + " pm.response.to.have.status(200);", + "});", + "", + "var entity = pm.response.json().entity;", + "", + "// A Link carries no file MIME type, so it could never satisfy a mimeTypes filter.", + "pm.test(\"A mimeTypes filter drops links\", function () {", + " pm.expect(entity.linkCount).to.eql(0);", + " pm.expect(entity.hasMoreLinks).to.eql(false);", + "});", + "", + "pm.test(\"The mimeType filter itself still works\", function () {", + " entity.list.forEach(function (item) {", + " pm.expect(item.mimeType).to.not.eql(\"application/dotlink\");", + " });", + "});" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"assetPath\": \"//{{testSiteName}}/\",\n \"showLinks\": true,\n \"mimeTypes\": [\n \"image/jpeg\"\n ],\n \"maxResults\": 50\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{serverURL}}/api/v1/drive/search", + "host": [ + "{{serverURL}}" + ], + "path": [ + "api", + "v1", + "drive", + "search" + ] + } + }, + "response": [] + }, + { + "name": "showLinks With filters.text - Filter Applied To Link Titles", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Status code should be 200\", function () {", + " pm.response.to.have.status(200);", + "});", + "", + "var entity = pm.response.json().entity;", + "var term = \"alpha\";", + "", + "pm.test(\"Link pagination contract survives filters.text\", function () {", + " pm.expect(entity).to.have.property(\"linkCount\");", + " pm.expect(entity).to.have.property(\"hasMoreLinks\");", + " pm.expect(entity).to.have.property(\"nextLinkCursor\");", + " pm.expect(entity.linkCount).to.be.at.least(0);", + "});", + "", + "var links = entity.list.filter(function (item) {", + " return item.mimeType === \"application/dotlink\";", + "});", + "", + "pm.test(\"linkCount agrees with the links in the page\", function () {", + " pm.expect(entity.linkCount).to.eql(links.length);", + "});", + "", + "pm.test(\"Every returned link title matches the filter term\", function () {", + " links.forEach(function (link) {", + " pm.expect(String(link.title).toLowerCase()).to.include(term);", + " });", + "});" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"assetPath\": \"//{{testSiteName}}/\",\n \"showLinks\": true,\n \"showFolders\": false,\n \"baseTypes\": [],\n \"filters\": {\n \"text\": \"alpha\"\n },\n \"maxResults\": 50\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{serverURL}}/api/v1/drive/search", + "host": [ + "{{serverURL}}" + ], + "path": [ + "api", + "v1", + "drive", + "search" + ] + }, + "description": "showLinks combined with filters.text. Link titles are narrowed in memory because links are not indexed in Elasticsearch, and this combination had no coverage at all before -- not even that it returns 200 rather than erroring. Menu links cannot be created over REST, so the substantive assertions (only the matching link comes back, case-insensitively) live in the ContentDriveLinksTest integration test. What is pinned here is that the combination is accepted, that the link pagination contract survives it, and that any link returned really does match the term." + }, + "response": [] + }, + { + "name": "showLinks With live true - No Duplicate Links", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Status code should be 200\", function () {", + " pm.response.to.have.status(200);", + "});", + "", + "var entity = pm.response.json().entity;", + "var links = entity.list.filter(function (item) {", + " return item.mimeType === \"application/dotlink\";", + "});", + "", + "pm.test(\"live:true must never return the same link twice\", function () {", + " var ids = links.map(function (link) { return link.identifier; });", + " var unique = ids.filter(function (id, index) { return ids.indexOf(id) === index; });", + " pm.expect(unique.length, JSON.stringify(ids)).to.eql(ids.length);", + "});", + "", + "pm.test(\"linkCount agrees with the links in the page\", function () {", + " pm.expect(entity.linkCount).to.eql(links.length);", + "});" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"assetPath\": \"//{{testSiteName}}/\",\n \"showLinks\": true,\n \"showFolders\": false,\n \"baseTypes\": [],\n \"live\": true,\n \"archived\": false,\n \"maxResults\": 50\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{serverURL}}/api/v1/drive/search", + "host": [ + "{{serverURL}}" + ], + "path": [ + "api", + "v1", + "drive", + "search" + ] + }, + "description": "Regression guard for showLinks + live:true -- the combination the redirect custom field sends as showWorking:false. Asking FolderFactoryImpl for working=false left the version-table predicate uncorrelated, so the query degenerated into a cross product and could return the same link many times. BrowserAPIImpl.getLinks now always asks for the working links and resolves 'live' by keeping the ones that carry a published version." + }, + "response": [] + } + ], + "description": "Coverage for the showLinks flag and the link pagination fields on POST /api/v1/drive/search (issue #36991)." + }, + { + "name": "Cleanup", + "item": [ + { + "name": "Delete Test Content Type", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Status code should be 200\", function () {", + " pm.response.to.have.status(200);", + "});", + "", + "pm.test(\"Content type deleted\", function () {", + " var jsonData = pm.response.json();", + " pm.expect(jsonData.errors.length).to.eql(0);", + "});" + ], + "type": "text/javascript" + } + } + ], + "request": { + "method": "DELETE", + "header": [], + "url": { + "raw": "{{serverURL}}/api/v1/contenttype/id/{{testContentTypeId}}", + "host": [ + "{{serverURL}}" + ], + "path": [ + "api", + "v1", + "contenttype", + "id", + "{{testContentTypeId}}" + ] + } + }, + "response": [] + }, + { + "name": "Archive Test Site", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Status code should be 200\", function () {", + " pm.response.to.have.status(200);", + "});", + "", + "pm.test(\"Site archived successfully\", function () {", + " var jsonData = pm.response.json();", + " pm.expect(jsonData.errors.length).to.eql(0);", + "});" + ], + "type": "text/javascript", + "packages": {}, + "requests": {} + } + } + ], + "request": { + "method": "PUT", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"archived\": true\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{serverURL}}/api/v1/site/{{testSiteId}}/_archive", + "host": [ + "{{serverURL}}" + ], + "path": [ + "api", + "v1", + "site", + "{{testSiteId}}", + "_archive" + ] + } + }, + "response": [] + }, + { + "name": "Delete Test Site", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Status code should be 200\", function () {", + " pm.response.to.have.status(200);", + "});", + "", + "pm.test(\"Site archived successfully\", function () {", + " var jsonData = pm.response.json();", + " pm.expect(jsonData.errors.length).to.eql(0);", + "});" + ], + "type": "text/javascript", + "packages": {}, + "requests": {} + } + } + ], + "request": { + "method": "DELETE", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"archived\": true\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "{{serverURL}}/api/v1/site/{{testSiteId}}", + "host": [ + "{{serverURL}}" + ], + "path": [ + "api", + "v1", + "site", + "{{testSiteId}}" + ] + } + }, + "response": [] + } + ], + "description": "Cleanup test data created during the test run." + } + ], + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{jwt}}", + "type": "string" + } + ] + }, + "event": [ + { + "listen": "prerequest", + "script": { + "type": "text/javascript", + "packages": {}, + "requests": {}, + "exec": [ + "sleep = function(milliseconds) {", + " const start = Date.now();", + " while (Date.now() - start < milliseconds) {}", + " console.log(`\u23f1\ufe0f Delayed ${milliseconds}ms`);", + "};", + "", + "sleep(3000);", + "", + "if (!pm.environment.get('jwt')) {", + " console.log(\"generating....\")", + " const serverURL = pm.environment.get('serverURL'); // Get the server URL from the environment variable", + " const apiUrl = `${serverURL}/api/v1/apitoken`; // Construct the full API URL", + "", + " if (!pm.environment.get('jwt')) {", + " const username = pm.environment.get(\"user\");", + " const password = pm.environment.get(\"password\");", + " const basicAuth = Buffer.from(`${username}:${password}`).toString('base64');", + "", + " const requestOptions = {", + " url: apiUrl,", + " method: \"POST\",", + " header: {", + " \"accept\": \"*/*\",", + " \"content-type\": \"application/json\",", + " \"Authorization\": `Basic ${basicAuth}`", + " },", + " body: {", + " mode: \"raw\",", + " raw: JSON.stringify({", + " \"expirationSeconds\": 7200,", + " \"userId\": \"dotcms.org.1\",", + " \"network\": \"0.0.0.0/0\",", + " \"claims\": {\"label\": \"postman-tests\"}", + " })", + " }", + " };", + "", + " pm.sendRequest(requestOptions, function (err, response) {", + " if (err) {", + " console.log(err);", + " } else {", + " const jwt = response.json().entity.jwt;", + " pm.environment.set('jwt', jwt);", + " console.log(jwt);", + " }", + " });", + " }", + "}", + "" + ] + } + }, + { + "listen": "test", + "script": { + "type": "text/javascript", + "packages": {}, + "requests": {}, + "exec": [ + "" + ] + } + } + ] +} From 44538a030fa4dcef94aa1c7d5505917da209c734 Mon Sep 17 00:00:00 2001 From: Jalinson Diaz Date: Tue, 15 Sep 2026 13:39:22 -0300 Subject: [PATCH 26/44] docs(content-drive): Mod + B hides the sidebar, not just the tree (#37426) The keyboard reference called it "show or hide the folder tree". It collapses the whole column, which now holds the all-site-content and System Host entries as well -- so the line named less than the shortcut does. Found by re-reading `docs/` against this change rather than by hitting it. The rest of the sweep came back clean: nothing in `docs/` or either CLAUDE.md named the boolean this branch replaced or the chip it renamed, and `/v1/drive` does not appear in the generated OpenAPI at all, so the new request field describes nothing there. Co-Authored-By: Claude Opus 5 (1M context) --- .../dot-content-drive-sidebar.component.spec.ts | 2 +- .../dot-content-drive-sidebar.component.ts | 2 +- docs/frontend/KEYBOARD_SHORTCUTS.md | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/core-web/libs/portlets/dot-content-drive/portlet/src/lib/components/dot-content-drive-sidebar/dot-content-drive-sidebar.component.spec.ts b/core-web/libs/portlets/dot-content-drive/portlet/src/lib/components/dot-content-drive-sidebar/dot-content-drive-sidebar.component.spec.ts index 6a40d55d1eb7..403b7602e8c4 100644 --- a/core-web/libs/portlets/dot-content-drive/portlet/src/lib/components/dot-content-drive-sidebar/dot-content-drive-sidebar.component.spec.ts +++ b/core-web/libs/portlets/dot-content-drive/portlet/src/lib/components/dot-content-drive-sidebar/dot-content-drive-sidebar.component.spec.ts @@ -29,8 +29,8 @@ import { createFakeSite } from '@dotcms/utils-testing'; import { DotContentDriveSidebarComponent } from './dot-content-drive-sidebar.component'; -import { DotContentDriveStore } from '../../store/dot-content-drive.store'; import { SYSTEM_HOST } from '../../shared/constants'; +import { DotContentDriveStore } from '../../store/dot-content-drive.store'; import { createSiteNode } from '../../utils/tree-folder.utils'; describe('DotContentDriveSidebarComponent', () => { diff --git a/core-web/libs/portlets/dot-content-drive/portlet/src/lib/components/dot-content-drive-sidebar/dot-content-drive-sidebar.component.ts b/core-web/libs/portlets/dot-content-drive/portlet/src/lib/components/dot-content-drive-sidebar/dot-content-drive-sidebar.component.ts index efdbcc4408d0..1098ecfe9c18 100644 --- a/core-web/libs/portlets/dot-content-drive/portlet/src/lib/components/dot-content-drive-sidebar/dot-content-drive-sidebar.component.ts +++ b/core-web/libs/portlets/dot-content-drive/portlet/src/lib/components/dot-content-drive-sidebar/dot-content-drive-sidebar.component.ts @@ -31,8 +31,8 @@ import { } from '@dotcms/portlets/content-drive/ui'; import { DotMessagePipe } from '@dotcms/ui'; -import { DotContentDriveStore } from '../../store/dot-content-drive.store'; import { SYSTEM_HOST } from '../../shared/constants'; +import { DotContentDriveStore } from '../../store/dot-content-drive.store'; import { appendLoadMoreNodes, mergeFolderNodePage } from '../../utils/functions'; /** * @description DotContentDriveSidebarComponent is the component that renders the sidebar for the content drive diff --git a/docs/frontend/KEYBOARD_SHORTCUTS.md b/docs/frontend/KEYBOARD_SHORTCUTS.md index f841d6f75a16..476b90cb70d3 100644 --- a/docs/frontend/KEYBOARD_SHORTCUTS.md +++ b/docs/frontend/KEYBOARD_SHORTCUTS.md @@ -12,7 +12,7 @@ which cannot arbitrate between two surfaces that want the same key. |---|---|---| | `/` | Focus the search field | `dot-content-drive-search-input`, `dot-asset-picker-toolbar` | | `Mod + K` | Focus the search field (alias for `/`) | as above | -| `Mod + B` | Show or hide the folder tree | `dot-content-drive-shell` | +| `Mod + B` | Show or hide the sidebar (the folder tree and the entries around it) | `dot-content-drive-shell` | | `Escape` | Clear the selection | `dot-content-drive-shell` | | `Escape` | Close the content side panel (wins while open) | `dot-edit-content-side-panel` | | `↑` `↓` | Move focus between rows | the shared listing | From 3455f648ae3751c147a040eb4e5547c47b5805ec Mon Sep 17 00:00:00 2001 From: Jalinson Diaz Date: Tue, 15 Sep 2026 13:44:57 -0300 Subject: [PATCH 27/44] fix(content-drive): the upload indicator names the site, not its root (#37426) "the root of demo.dotcms.com" was tried and reads as fussy for what it buys. The part an author needs is which SITE received the files, and the site row and the flat view put content in the same place anyway -- so naming the root added words without adding an answer. The System Host case is untouched and still says System Host, which is the distinction that actually mattered: there the switcher names a site that is not the destination. The message key added for it is removed rather than left behind. Co-Authored-By: Claude Opus 5 (1M context) --- .../dot-content-drive-shell.component.spec.ts | 9 +++++---- .../dot-content-drive-shell.component.ts | 13 ++++--------- .../webapp/WEB-INF/messages/Language.properties | 1 - 3 files changed, 9 insertions(+), 14 deletions(-) diff --git a/core-web/libs/portlets/dot-content-drive/portlet/src/lib/dot-content-drive-shell/dot-content-drive-shell.component.spec.ts b/core-web/libs/portlets/dot-content-drive/portlet/src/lib/dot-content-drive-shell/dot-content-drive-shell.component.spec.ts index d2d47ce30c93..3efc5be5018b 100644 --- a/core-web/libs/portlets/dot-content-drive/portlet/src/lib/dot-content-drive-shell/dot-content-drive-shell.component.spec.ts +++ b/core-web/libs/portlets/dot-content-drive/portlet/src/lib/dot-content-drive-shell/dot-content-drive-shell.component.spec.ts @@ -2097,9 +2097,10 @@ describe('DotContentDriveShellComponent', () => { ); }); - it('should name the site ROOT, not just the site, when no folder is chosen', () => { - // The flat view has no path column, so the indicator is the only thing that says where - // a file went. "demo.com" reads as "somewhere on this site"; the root is the answer. + it('should name the site when no folder is chosen', () => { + // The site, not the root of it. Naming the root was tried and read as fussy for what + // it bought: the destination that matters to the author is which site received the + // files, and the site row and the flat view put them in the same place anyway. store.currentSite.mockReturnValue(MOCK_SITES[0]); store.$systemHostSelected.mockReturnValue(false); @@ -2110,7 +2111,7 @@ describe('DotContentDriveShellComponent', () => { }); expect(store.startExternalRun).toHaveBeenCalledWith( - expect.objectContaining({ targetLabel: 'content-drive.upload.target.site-root' }) + expect.objectContaining({ targetLabel: MOCK_SITES[0].hostname }) ); }); diff --git a/core-web/libs/portlets/dot-content-drive/portlet/src/lib/dot-content-drive-shell/dot-content-drive-shell.component.ts b/core-web/libs/portlets/dot-content-drive/portlet/src/lib/dot-content-drive-shell/dot-content-drive-shell.component.ts index 6492ac6c3a73..8c59c290dacf 100644 --- a/core-web/libs/portlets/dot-content-drive/portlet/src/lib/dot-content-drive-shell/dot-content-drive-shell.component.ts +++ b/core-web/libs/portlets/dot-content-drive/portlet/src/lib/dot-content-drive-shell/dot-content-drive-shell.component.ts @@ -1494,15 +1494,10 @@ export class DotContentDriveShellComponent implements OnDestroy { return this.#dotMessageService.get('content-drive.sidebar.system-host'); } - // The site ROOT, not just the site. With no folder chosen the files land at the root, and - // in the flat view there is no path column to show that afterwards -- so this sentence is - // the only thing that says where they went. A bare hostname reads as "somewhere on this - // site", which on a site with folders is the question rather than the answer. - const hostname = this.#store.currentSite()?.hostname; - - return hostname - ? this.#dotMessageService.get('content-drive.upload.target.site-root', hostname) - : undefined; + // The site. With no folder chosen the files land at its root, but which SITE received them + // is the part an author needs, and the site row and the flat view put content in the same + // place anyway -- so naming the root adds words without adding an answer. + return this.#store.currentSite()?.hostname; } protected uploadByBaseType( diff --git a/dotCMS/src/main/webapp/WEB-INF/messages/Language.properties b/dotCMS/src/main/webapp/WEB-INF/messages/Language.properties index 8ccfc21eb551..3b4d0b20d228 100644 --- a/dotCMS/src/main/webapp/WEB-INF/messages/Language.properties +++ b/dotCMS/src/main/webapp/WEB-INF/messages/Language.properties @@ -7286,7 +7286,6 @@ content-drive.no-permission.title=Permission required content-drive.no-permission.add-to-folder=You don't have permission to add content to this folder. content-drive.no-permission.add-to-site=You don't have permission to add content to this site. content-drive.upload=Upload -content-drive.upload.target.site-root=the root of {0} content-drive.upload-asset=Upload Asset content-drive.upload-file=Upload File content-drive.base-type.content=Content From afb26fa2822a1b3a0b0c9bc9e5582fd1d74e071e Mon Sep 17 00:00:00 2001 From: Jalinson Diaz Date: Wed, 16 Sep 2026 11:38:02 -0300 Subject: [PATCH 28/44] feat(content-drive): report a run in flight as a status toast (#37426) The toolbar drew the in-flight indicator itself, at the end of the filter row. That row is where the user works, so filter chips coming and going beside a status that appears and disappears moved the controls under the pointer. It is now a toast, which says the same thing without competing for that space and puts the in-flight state and its outcome on one surface instead of an indicator in one place and a toast in another. `dot-status-toast` is a new outlet rather than a variant of `dot-toast`, because the two carry different things. That one is a report: a title plus a detail paragraph, often several lines, frequently with markup, and 350px wide because it has to be. This one states an outcome in a couple of words, so it is sized to its text and drops `detail` entirely rather than growing to fit something a caller should not have sent it. Measured: 166px for "Uploaded", 324px when the label names a destination, no clipping at either end. It claims a key. A portlet provides ONE MessageService, so an outlet with no key renders every message on it -- sharing a shell with `dot-toast` showed each status twice, once wide at the top and once compact at the bottom. It re-raises only when the wording changes. PrimeNG has no update, so reporting again means clearing and raising again, and the indicator this replaced simply changed its text. A second run still collapses the label to the count form, which IS new information and does re-raise. The percentage is deliberately not carried over: nothing sets a run's `processed` -- `updateExternalRun` has no callers -- so it could never render. Two things fixed in the same pass, both found by looking at it running: Uploads had no wording of their own, so they borrowed the workflow sentence and read "Applying Upload to demo.dotcms.com" -- a phrasing for an action applied TO content, not for files going INTO a place. They now carry their own label key, the way the backgrounded variant already did. A backgrounded upload announced itself twice, as the wide advisory and as the status. The advisory is gone and its two keys with it. What is genuinely lost is the sentence spelling out that the author may leave the page; the wording still carries the fact, it no longer argues for it. Colour comes from Lara through the dotCMS preset, keyed on severity. The prototype this follows used a dark pill, which is deliberately not reproduced: a black surface is not a pattern in this design system. Co-Authored-By: Claude Opus 5 (1M context) --- .../dot-content-drive-toolbar.component.html | 36 ---- ...ot-content-drive-toolbar.component.spec.ts | 196 +++++++++--------- .../dot-content-drive-toolbar.component.ts | 56 ++++- .../dot-content-drive-shell.component.html | 5 + .../dot-content-drive-shell.component.spec.ts | 66 +++--- .../dot-content-drive-shell.component.ts | 30 ++- core-web/libs/ui/src/index.ts | 1 + .../dot-status-toast.component.html | 38 ++++ .../dot-status-toast.component.scss | 82 ++++++++ .../dot-status-toast.component.spec.ts | 105 ++++++++++ .../dot-status-toast.component.ts | 61 ++++++ .../WEB-INF/messages/Language.properties | 3 +- 12 files changed, 497 insertions(+), 182 deletions(-) create mode 100644 core-web/libs/ui/src/lib/components/dot-status-toast/dot-status-toast.component.html create mode 100644 core-web/libs/ui/src/lib/components/dot-status-toast/dot-status-toast.component.scss create mode 100644 core-web/libs/ui/src/lib/components/dot-status-toast/dot-status-toast.component.spec.ts create mode 100644 core-web/libs/ui/src/lib/components/dot-status-toast/dot-status-toast.component.ts diff --git a/core-web/libs/portlets/dot-content-drive/portlet/src/lib/components/dot-content-drive-toolbar/dot-content-drive-toolbar.component.html b/core-web/libs/portlets/dot-content-drive/portlet/src/lib/components/dot-content-drive-toolbar/dot-content-drive-toolbar.component.html index 83b814e57a3f..fc7d8915dce6 100644 --- a/core-web/libs/portlets/dot-content-drive/portlet/src/lib/components/dot-content-drive-toolbar/dot-content-drive-toolbar.component.html +++ b/core-web/libs/portlets/dot-content-drive/portlet/src/lib/components/dot-content-drive-toolbar/dot-content-drive-toolbar.component.html @@ -122,42 +122,6 @@ - - - @if ($hasRunInFlight()) { -
- - - - - @if ($actionExecutionPercent() !== undefined) { - - {{ $actionExecutionPercent() }}% - - } -
- }
diff --git a/core-web/libs/portlets/dot-content-drive/portlet/src/lib/components/dot-content-drive-toolbar/dot-content-drive-toolbar.component.spec.ts b/core-web/libs/portlets/dot-content-drive/portlet/src/lib/components/dot-content-drive-toolbar/dot-content-drive-toolbar.component.spec.ts index 44a02fba011a..79c3c8697fcb 100644 --- a/core-web/libs/portlets/dot-content-drive/portlet/src/lib/components/dot-content-drive-toolbar/dot-content-drive-toolbar.component.spec.ts +++ b/core-web/libs/portlets/dot-content-drive/portlet/src/lib/components/dot-content-drive-toolbar/dot-content-drive-toolbar.component.spec.ts @@ -32,7 +32,8 @@ import { DotFilterFacade, DotFilterValue, DotLanguageFilterChipComponent, - isCanonicalChipOrder + isCanonicalChipOrder, + STATUS_TOAST_KEY } from '@dotcms/ui'; import { createFakeTextField, mockLocales, MockDotMessageService } from '@dotcms/utils-testing'; @@ -68,6 +69,9 @@ describe('DotContentDriveToolbarComponent', () => { // Real signals so the component's computeds re-run when they change const isTreeExpandedSignal = signal(false); const allSiteContentSelectedSignal = signal(false); + // The toolbar reports a run by raising a status message rather than drawing it, so the spec owns + // the service it pushes through. + const messageServiceSpy = { add: vi.fn(), clear: vi.fn() }; const filtersSignal = signal({}); const selectedItemsSignal = signal([]); const selectedNodeSignal = signal< @@ -202,7 +206,7 @@ describe('DotContentDriveToolbarComponent', () => { }, // Needed once a selection exists: that mounts the workflow-actions child, which injects // both of these. - mockProvider(MessageService, { add: vi.fn() }), + mockProvider(MessageService, messageServiceSpy), mockProvider(DotContentDriveNavigationService, { editContent: vi.fn(), editPage: vi.fn() @@ -218,6 +222,8 @@ describe('DotContentDriveToolbarComponent', () => { }); beforeEach(() => { + messageServiceSpy.add.mockClear(); + messageServiceSpy.clear.mockClear(); spectator = createComponent(); store = spectator.inject(DotContentDriveStore, true); spectator.detectChanges(); @@ -793,35 +799,53 @@ describe('DotContentDriveToolbarComponent', () => { }); }); - describe('running-action indicator', () => { - it('should stay hidden when nothing is running', () => { + describe('running-action reporting', () => { + // The toolbar used to draw this at the end of its filter row. It now raises a status toast + // instead, so these assert the message rather than markup the toolbar no longer owns — the + // label itself is still composed here, which is why those assertions are unchanged. + const statusMessages = () => + (messageServiceSpy.add.mock.calls as unknown[][]) + .map(([message]) => message as { key?: string; summary?: string }) + .filter((message) => message.key === STATUS_TOAST_KEY); + + it('should raise nothing when nothing is running', () => { spectator.detectChanges(); + spectator.flushEffects(); - expect(spectator.query(byTestId('action-execution-indicator'))).toBeNull(); + expect(statusMessages()).toHaveLength(0); }); it('should report the action and the number of items once a run starts', () => { - // The toolbar is the only place still reporting the run after the Action Center dialog is - // closed, which is the whole reason the indicator lives out here. + // The toolbar is the only place still reporting the run after the Action Center dialog + // is closed, which is the whole reason this is reported outside it. activeRunCountSignal.set(1); actionExecutionSignal.set({ actionName: 'Publish', total: 3 }); spectator.detectChanges(); + spectator.flushEffects(); - const indicator = spectator.query(byTestId('action-execution-indicator')); - - expect(indicator).toBeTruthy(); + expect(statusMessages()).toHaveLength(1); expect(spectator.component.$actionExecutionLabel()).toBe( 'content-drive.action-center.applying' ); }); - it('should not render markup carried by the action name', () => { - // Workflow action names come from the backend verbatim (`$selectedAction()?.name`), and - // the label is placed in the DOM as HTML so the message's own `` renders. A name - // carrying markup must not become live DOM — an event-handler attribute least of all. - // - // The shared mock returns the bare key, which would make this pass without rendering - // anything; the real message has to be in play for the assertion to mean something. + it('should say the run is still going rather than reporting an outcome', () => { + activeRunCountSignal.set(1); + actionExecutionSignal.set({ actionName: 'Publish', total: 3 }); + spectator.detectChanges(); + spectator.flushEffects(); + + const [message] = statusMessages(); + + expect(message).toEqual( + expect.objectContaining({ sticky: true, icon: 'pi pi-spin pi-spinner' }) + ); + }); + + it('should escape markup carried by the action name', () => { + // Workflow action names come from the backend verbatim, and the label is rendered as + // HTML so the message's own `` shows. A name carrying markup must not become live + // DOM — escaping happens as the label is composed, which is where this now asserts. const messageService = spectator.inject(DotMessageService); vi.spyOn(messageService, 'get').mockImplementation((key: string, ...args: string[]) => @@ -832,60 +856,79 @@ describe('DotContentDriveToolbarComponent', () => { activeRunCountSignal.set(1); actionExecutionSignal.set({ - actionName: '', + actionName: '', total: 3 }); spectator.detectChanges(); + spectator.flushEffects(); - const indicator = spectator.query(byTestId('action-execution-indicator')); + const label = spectator.component.$actionExecutionLabel(); - // Asserted structurally rather than by searching the markup for "onerror": once the name - // is escaped it renders as visible text that legitimately still contains that word. - expect(indicator?.querySelector('img')).toBeNull(); - expect(indicator?.querySelector('[onerror]')).toBeNull(); - // …and the name is still shown to the user, just as text. - expect(indicator?.textContent).toContain(' { + it('should clear the status once the run settles', () => { activeRunCountSignal.set(1); actionExecutionSignal.set({ actionName: 'Publish', total: 3 }); spectator.detectChanges(); + spectator.flushEffects(); - actionExecutionSignal.set(undefined); - // Settling clears the count too; the indicator now keys visibility off it so that - // several runs (where no single one is named) still show something. activeRunCountSignal.set(0); + actionExecutionSignal.set(undefined); + spectator.detectChanges(); + spectator.flushEffects(); + + expect(messageServiceSpy.clear).toHaveBeenCalledWith(STATUS_TOAST_KEY); + }); + + it('should not re-raise while the same run keeps going', () => { + // The indicator this replaced changed its text in place. Clearing and raising again on + // every store touch would dismiss and re-animate the toast for no new information. + activeRunCountSignal.set(1); + actionExecutionSignal.set({ actionName: 'Publish', total: 3 }); + spectator.detectChanges(); + spectator.flushEffects(); + + const raisedOnce = statusMessages().length; + + // Something unrelated changes; the run and its wording do not. + siteCanAddChildrenSignal.set(false); spectator.detectChanges(); + spectator.flushEffects(); - expect(spectator.query(byTestId('action-execution-indicator'))).toBeNull(); + expect(statusMessages()).toHaveLength(raisedOnce); }); - // ---- FR-017: several runs at once ---- + it('should report the new wording when a second run joins', () => { + activeRunCountSignal.set(1); + actionExecutionSignal.set({ actionName: 'Publish', total: 3 }); + spectator.detectChanges(); + spectator.flushEffects(); - it('should collapse to a count when several runs are in flight', () => { - // `actionExecution` is undefined with more than one run: naming one of several - // arbitrarily is worse than naming none. + // A second run collapses the label to the count form, which IS new information. activeRunCountSignal.set(2); actionExecutionSignal.set(undefined); spectator.detectChanges(); + spectator.flushEffects(); - expect(spectator.query(byTestId('action-execution-indicator'))).toBeTruthy(); - expect(spectator.component.$actionExecutionLabel()).toBe( - 'content-drive.action-center.applying-many' - ); + expect(statusMessages().length).toBeGreaterThan(1); }); - it('should still hide the indicator when nothing at all is running', () => { - activeRunCountSignal.set(0); + it('should collapse to a count when several runs are in flight', () => { + // With several at once the store leaves the run undefined on purpose: name none of + // them and report the number instead. + activeRunCountSignal.set(3); actionExecutionSignal.set(undefined); spectator.detectChanges(); + spectator.flushEffects(); - expect(spectator.query(byTestId('action-execution-indicator'))).toBeNull(); + expect(statusMessages().length).toBeGreaterThan(0); + expect(spectator.component.$actionExecutionLabel()).toBe( + 'content-drive.action-center.applying-many' + ); }); - // ---- FR-010: name the item when there is one ---- - it('should name the item, not a count, when the run is over a single thing', () => { // "Applying Publish to 1 item(s)" is worse than useless on a context-menu action: the // author knows it is one item, what they cannot see is *which*. @@ -896,6 +939,7 @@ describe('DotContentDriveToolbarComponent', () => { targetLabel: 'My Page' }); spectator.detectChanges(); + spectator.flushEffects(); expect(spectator.component.$actionExecutionLabel()).toBe( 'content-drive.action-center.applying-item' @@ -906,17 +950,16 @@ describe('DotContentDriveToolbarComponent', () => { activeRunCountSignal.set(1); actionExecutionSignal.set({ actionName: 'Publish', total: 12 }); spectator.detectChanges(); + spectator.flushEffects(); expect(spectator.component.$actionExecutionLabel()).toBe( 'content-drive.action-center.applying' ); }); - // ---- FR-011: the item name is author-supplied content ---- - - it('should not render markup carried by the item name', () => { + it('should escape markup carried by the item name', () => { // `actionName` comes from the backend; a title is typed by an author, so this is the - // likelier of the two to carry markup and the one that must not become live DOM. + // likelier of the two to carry markup. const messageService = spectator.inject(DotMessageService); vi.spyOn(messageService, 'get').mockImplementation((key: string, ...args: string[]) => @@ -929,64 +972,15 @@ describe('DotContentDriveToolbarComponent', () => { actionExecutionSignal.set({ actionName: 'Publish', total: 1, - targetLabel: '' + targetLabel: '' }); spectator.detectChanges(); + spectator.flushEffects(); - const indicator = spectator.query(byTestId('action-execution-indicator')); - - expect(indicator?.querySelector('img')).toBeNull(); - expect(indicator?.querySelector('[onerror]')).toBeNull(); - expect(indicator?.textContent).toContain(' { - // Progress readback is the backend's largest piece of hidden work. Until it lands every - // run is indeterminate, and the indicator must not imply a position it does not have. - activeRunCountSignal.set(1); - actionExecutionSignal.set({ actionName: 'Publish', total: 50 }); - spectator.detectChanges(); - - expect(spectator.query(byTestId('action-execution-indicator'))).toBeTruthy(); - expect(spectator.query(byTestId('action-execution-progress'))).toBeNull(); - }); - - it('should show the position once the run reports one', () => { - activeRunCountSignal.set(1); - actionExecutionSignal.set({ actionName: 'Publish', total: 50, processed: 20 }); - spectator.detectChanges(); - - const progress = spectator.query(byTestId('action-execution-progress')); - - expect(progress).toBeTruthy(); - expect(spectator.component.$actionExecutionPercent()).toBe(40); - }); - - it('should treat a reported zero as a position, not as absence', () => { - // `processed: 0` is a run that has genuinely done nothing yet, which is different from a - // run that cannot say. A truthiness check would collapse the two. - activeRunCountSignal.set(1); - actionExecutionSignal.set({ actionName: 'Publish', total: 50, processed: 0 }); - spectator.detectChanges(); - - expect(spectator.query(byTestId('action-execution-progress'))).toBeTruthy(); - expect(spectator.component.$actionExecutionPercent()).toBe(0); - }); - - // ---- FR-035: announce state changes, not every tick ---- - - it('should keep progress updates out of the live region', () => { - // The indicator is a polite live region. A fifty-file upload that re-announced on every - // tick would speak fifty times; the value stays queryable instead of being pushed. - activeRunCountSignal.set(1); - actionExecutionSignal.set({ actionName: 'Publish', total: 50, processed: 20 }); - spectator.detectChanges(); - - const progress = spectator.query(byTestId('action-execution-progress')); + const label = spectator.component.$actionExecutionLabel(); - expect(progress?.getAttribute('aria-live')).toBe('off'); + expect(label).not.toContain(' this.$activeRunCount() > 0); + /** What the status toast is currently saying, so an unchanged run is not re-raised. */ + #shownRunLabel: string | undefined; + + /** + * Mirrors the run in flight into the status toast. + * + * The toolbar used to draw this itself, at the end of the filter row. It moved because the row + * is where the user works — filter chips come and go beside it — and a status that appears and + * disappears there shifts the controls under the pointer. A toast says the same thing without + * competing for that space, and gives the in-flight state and its outcome one surface instead + * of an indicator here and a toast elsewhere. + * + * Sticky while the run lasts and cleared when it settles: the outcome toast that follows is + * raised by the shell, which is where results are turned into copy. + * + * The percentage the old indicator could show is deliberately not carried over. Nothing ever + * sets a run's `processed` — `updateExternalRun` has no callers — so it could not render, and + * the app's HTTP backend does not report upload progress either. + */ + readonly runToastSync = effect(() => { + const running = this.$hasRunInFlight(); + const label = this.$actionExecutionLabel(); + + untracked(() => { + if (!running) { + this.#shownRunLabel = undefined; + this.#messageService.clear(STATUS_TOAST_KEY); + + return; + } + + // Only when the wording actually changes. PrimeNG has no update, so re-reporting means + // clearing and raising again — and the old inline indicator simply changed its text, + // so re-animating on every store touch would be a behaviour this replaced, not kept. + // The label does change while runs are in flight: a second run starting collapses it to + // the count form, and finishing brings the named form back. + if (label === this.#shownRunLabel) { + return; + } + + this.#shownRunLabel = label; + this.#messageService.clear(STATUS_TOAST_KEY); + this.#messageService.add({ + key: STATUS_TOAST_KEY, + severity: 'info', + summary: label, + icon: 'pi pi-spin pi-spinner', + sticky: true + }); + }); + }); + /** * Resolved indicator label. Built here rather than in the template because `DotMessagePipe` takes * `string[]` arguments and the item count is a number. diff --git a/core-web/libs/portlets/dot-content-drive/portlet/src/lib/dot-content-drive-shell/dot-content-drive-shell.component.html b/core-web/libs/portlets/dot-content-drive/portlet/src/lib/dot-content-drive-shell/dot-content-drive-shell.component.html index 957e4812ddee..32409169e5bd 100644 --- a/core-web/libs/portlets/dot-content-drive/portlet/src/lib/dot-content-drive-shell/dot-content-drive-shell.component.html +++ b/core-web/libs/portlets/dot-content-drive/portlet/src/lib/dot-content-drive-shell/dot-content-drive-shell.component.html @@ -64,6 +64,11 @@ + + + @if ($contextMenuData()?.showAddToBundle) { { ); }); + it('should describe itself as an upload, not as an action applied to a site', () => { + // Without a label of its own the run falls to the workflow sentence, which reads + // "Applying Upload to demo.dotcms.com" — phrased for an action applied TO content, + // not for files going INTO a place. + selectUploadType({ + targetFolder: TARGET_FOLDER_DATA, + files: createFileList([createFile('a.png')]), + baseType: 'DOTASSET' + }); + + expect(store.startExternalRun).toHaveBeenCalledWith( + expect.objectContaining({ labelKey: 'content-drive.upload.indicator' }) + ); + }); + it('should target System Host when that is where the batch lands, not the switcher site', () => { // The switcher still names a site while System Host is browsed, and that site is // context rather than the destination. Uploading here with the site's identifier @@ -2253,11 +2268,10 @@ describe('DotContentDriveShellComponent', () => { baseType: 'DOTASSET' }); - expect(messageService.add).toHaveBeenCalledWith( - expect.objectContaining({ - severity: 'info', - detail: 'content-drive.upload.toast.backgrounded-detail' - }) + // The advisory toast this used to assert is gone: the status it sat beside says + // "in the background" itself, and both on screen announced one upload twice. + expect(store.startExternalRun).toHaveBeenCalledWith( + expect.objectContaining({ labelKey: 'content-drive.upload.indicator.background' }) ); }); @@ -2289,13 +2303,17 @@ describe('DotContentDriveShellComponent', () => { baseType: 'DOTASSET' }); - expect(dotMessageService.get).toHaveBeenCalledWith( - 'content-drive.upload.toast.backgrounded-detail', - '2' + expect(store.startExternalRun).toHaveBeenCalledWith( + expect.objectContaining({ + labelKey: 'content-drive.upload.indicator.background', + total: 2 + }) ); - expect(dotMessageService.get).not.toHaveBeenCalledWith( - 'content-drive.upload.toast.backgrounded-detail', - '3' + expect(store.startExternalRun).not.toHaveBeenCalledWith( + expect.objectContaining({ + labelKey: 'content-drive.upload.indicator.background', + total: 3 + }) ); }); @@ -2348,9 +2366,11 @@ describe('DotContentDriveShellComponent', () => { baseType: 'DOTASSET' }); - expect(dotMessageService.get).toHaveBeenCalledWith( - 'content-drive.upload.toast.backgrounded-detail', - '2' + expect(store.startExternalRun).toHaveBeenCalledWith( + expect.objectContaining({ + labelKey: 'content-drive.upload.indicator.background', + total: 2 + }) ); }); @@ -2839,11 +2859,8 @@ describe('DotContentDriveShellComponent', () => { baseType: 'DOTASSET' }); - expect(messageService.add).toHaveBeenCalledWith( - expect.objectContaining({ - severity: 'info', - detail: 'content-drive.upload.toast.backgrounded-detail' - }) + expect(store.startExternalRun).toHaveBeenCalledWith( + expect.objectContaining({ labelKey: 'content-drive.upload.indicator.background' }) ); // Still nothing that names it a success: the files do not exist yet. expect(messageService.add).not.toHaveBeenCalledWith( @@ -2963,13 +2980,10 @@ describe('DotContentDriveShellComponent', () => { baseType: 'DOTASSET' }); - expect(addSpy).toHaveBeenCalledTimes(1); - expect(addSpy).toHaveBeenCalledWith( - expect.objectContaining({ - severity: 'info', - summary: 'content-drive.upload.toast.backgrounded' - }) - ); + // Nothing at all now. The handoff advisory this used to assert was removed because + // the status toast beside it already said the upload was in the background, and one + // upload announcing itself twice is the noise this replaced. + expect(addSpy).not.toHaveBeenCalled(); }); it('should not announce an upload the listing now shows', () => { diff --git a/core-web/libs/portlets/dot-content-drive/portlet/src/lib/dot-content-drive-shell/dot-content-drive-shell.component.ts b/core-web/libs/portlets/dot-content-drive/portlet/src/lib/dot-content-drive-shell/dot-content-drive-shell.component.ts index 8c59c290dacf..1d5f7c7a8958 100644 --- a/core-web/libs/portlets/dot-content-drive/portlet/src/lib/dot-content-drive-shell/dot-content-drive-shell.component.ts +++ b/core-web/libs/portlets/dot-content-drive/portlet/src/lib/dot-content-drive-shell/dot-content-drive-shell.component.ts @@ -66,6 +66,7 @@ import { DotKeyboardShortcutUnregister, hasOverlayAbove, DotMessagePipe, + DotStatusToastComponent, DotToastComponent, DotUploadDropzoneComponent, DotUploadTypeSelectorComponent @@ -131,6 +132,7 @@ import { describeUploadFailures } from '../utils/upload-failures'; MessageModule, DotMessagePipe, DotUploadDropzoneComponent, + DotStatusToastComponent, DotToastComponent, DotEditContentSidePanelComponent, ProgressSpinnerModule, @@ -1539,6 +1541,10 @@ export class DotContentDriveShellComponent implements OnDestroy { // freshly chosen files, so two uploads at once is legitimate rather than a double-fire. operation: `${UPLOAD_BATCH_OPERATION}:${(this.#uploadSequence += 1)}`, actionName: this.#dotMessageService.get('content-drive.upload'), + // Its own wording rather than the workflow sentence. Without this the run reads + // "Applying Upload to demo.dotcms.com" — a phrasing for an action applied TO content, + // which is not what putting files INTO a place is. + labelKey: 'content-drive.upload.indicator', total: files.length, targetLabel: this.uploadTargetLabel(hostFolder), // Empty on purpose. The indicator speaks only for runs with nothing to mark, since a @@ -1650,22 +1656,14 @@ export class DotContentDriveShellComponent implements OnDestroy { baseType ); - // The one notification this flow raises, and the only in-flight fact worth - // one: until the handle existed, leaving lost the batch and the page guard - // said so; now leaving costs nothing. That rule changed with no visible - // cause, and the indicator cannot report it — it says work is happening, not - // that the author is released from it. - this.#messageService.add({ - severity: 'info', - summary: this.#dotMessageService.get( - 'content-drive.upload.toast.backgrounded' - ), - detail: this.#dotMessageService.get( - 'content-drive.upload.toast.backgrounded-detail', - String(submitted) - ), - life: SUCCESS_MESSAGE_LIFE - }); + // No notification here any more. This used to raise one, because the + // indicator could say work was happening but not that the page guard had + // released the author. The status toast that replaced the indicator says + // "in the background" itself, and having both on screen meant a backgrounded + // upload announced itself twice, once wide and once compact. + // + // What is genuinely lost is the sentence spelling out that the author may + // leave the page. The wording carries the fact; it no longer argues for it. // Nothing else to do, and deliberately nothing. A `202` means the batch is queued, // not that any file exists, so reloading here refetches a folder whose files diff --git a/core-web/libs/ui/src/index.ts b/core-web/libs/ui/src/index.ts index 5b0a3a58c4b7..a5b6eb905a9a 100644 --- a/core-web/libs/ui/src/index.ts +++ b/core-web/libs/ui/src/index.ts @@ -82,6 +82,7 @@ export * from './lib/components/dot-folder-list-view/constants'; export { DotSiteComponent } from './lib/components/dot-site/dot-site.component'; export * from './lib/components/dot-theme/dot-theme.component'; +export * from './lib/components/dot-status-toast/dot-status-toast.component'; export * from './lib/components/dot-toast/dot-toast.component'; export * from './lib/components/dot-upload-button/dot-upload-button.component'; export * from './lib/components/dot-upload-dropzone/dot-upload-dropzone.component'; diff --git a/core-web/libs/ui/src/lib/components/dot-status-toast/dot-status-toast.component.html b/core-web/libs/ui/src/lib/components/dot-status-toast/dot-status-toast.component.html new file mode 100644 index 000000000000..93d165b71b8f --- /dev/null +++ b/core-web/libs/ui/src/lib/components/dot-status-toast/dot-status-toast.component.html @@ -0,0 +1,38 @@ + + + + + @if (message.icon) { + + + } @else { + + } + + +
+ +
+
+ + +
+
diff --git a/core-web/libs/ui/src/lib/components/dot-status-toast/dot-status-toast.component.scss b/core-web/libs/ui/src/lib/components/dot-status-toast/dot-status-toast.component.scss new file mode 100644 index 000000000000..885047acfbfd --- /dev/null +++ b/core-web/libs/ui/src/lib/components/dot-status-toast/dot-status-toast.component.scss @@ -0,0 +1,82 @@ +:host { + // PrimeNG ships unlayered, so its own rules outrank Tailwind utilities. These have to be + // written as CSS against its class names rather than as classes in the template. + // + // Values are set directly rather than as `var(--p-toast-*, fallback)`: the theme DEFINES those + // variables, so a fallback never applies and the rule silently has no effect. That is why the + // summary kept rendering at the theme's 14px while this file asked for something smaller. + ::ng-deep { + // Sized to its content, not to a fixed 350px — a two-word outcome should not sit in a slab. + // It grows with the label instead of clipping it, up to a ceiling where it wraps: a status + // naming an unexpected destination is longer than one that does not, and both have to fit. + .p-toast-message { + width: fit-content; + max-width: min(28rem, calc(100vw - 2rem)); + margin-left: auto; + margin-right: auto; + } + + .p-toast-message-content { + align-items: center; + gap: 0.625rem; + padding: 0.625rem 0.75rem; + } + + // `min-width: 0` is what lets the text shrink inside the flex row. Without it the row + // refuses to give ground and the label overflows its own box — which read as the first + // characters being cut off rather than as the box being too small. + .p-toast-message-text { + flex: 0 1 auto; + min-width: 0; + margin: 0; + } + + .p-toast-summary { + font-size: 0.8125rem; + font-weight: 500; + line-height: 1.3; + // Wraps rather than truncates: a destination the user cannot infer is the one thing + // this line exists to say, so cutting it off defeats the point. + overflow-wrap: anywhere; + } + + .p-toast-message-icon { + display: inline-flex; + align-items: center; + font-size: 1rem; + flex: 0 0 auto; + } + + // Sits in the row rather than in the corner of a box, which is what makes the pill read as + // one object instead of a panel with a control on it. + .p-toast-close-button { + display: inline-flex; + align-items: center; + justify-content: center; + flex: 0 0 auto; + align-self: flex-start; + margin-left: 0.25rem; + padding: 0; + width: 1.25rem; + height: 1.25rem; + border: none; + border-radius: 50%; + background: transparent; + color: inherit; + cursor: pointer; + opacity: 0.7; + + &:hover { + opacity: 1; + } + + // `line-height: 1` and a block icon: the glyph otherwise keeps its own line box and + // sits low inside the circle, which reads as a misaligned control. + i { + display: block; + font-size: 0.75rem; + line-height: 1; + } + } + } +} diff --git a/core-web/libs/ui/src/lib/components/dot-status-toast/dot-status-toast.component.spec.ts b/core-web/libs/ui/src/lib/components/dot-status-toast/dot-status-toast.component.spec.ts new file mode 100644 index 000000000000..30da9dabea83 --- /dev/null +++ b/core-web/libs/ui/src/lib/components/dot-status-toast/dot-status-toast.component.spec.ts @@ -0,0 +1,105 @@ +import { Spectator, createComponentFactory } from '@openng/spectator/vitest'; + +import { MessageService } from 'primeng/api'; + +import { DotStatusToastComponent, STATUS_TOAST_KEY } from './dot-status-toast.component'; + +describe('DotStatusToastComponent', () => { + let spectator: Spectator; + let messageService: MessageService; + + const createComponent = createComponentFactory({ + component: DotStatusToastComponent, + providers: [MessageService] + }); + + const raise = (message: Parameters[0]) => { + messageService.add({ key: STATUS_TOAST_KEY, ...message }); + spectator.detectChanges(); + }; + + const box = () => spectator.query('.p-toast-message'); + + beforeEach(() => { + spectator = createComponent(); + messageService = spectator.inject(MessageService); + }); + + it('should show the outcome and nothing else', () => { + // The whole point of this outlet: one short line. An outcome that needs a paragraph is a + // report, and reports belong in dot-toast, which is sized for them. + raise({ severity: 'success', summary: 'Uploaded' }); + + expect(spectator.query('[data-testid="status-toast-summary"]')?.textContent?.trim()).toBe( + 'Uploaded' + ); + }); + + it('should carry the severity so the theme can colour it', () => { + // Colour comes from the severity through the dotCMS PrimeNG preset — never hardcoded, and + // never the dark pill from the prototype, which is not a pattern in this system. + raise({ severity: 'success', summary: 'Uploaded' }); + + expect(box()?.getAttribute('data-pc-severity') ?? box()?.className).toContain('success'); + }); + + it('should offer a way to dismiss it', () => { + raise({ severity: 'success', summary: 'Uploaded' }); + + expect(spectator.query('[data-testid="status-toast-close"]')).toBeTruthy(); + }); + + it('should clear the message when dismissed', () => { + raise({ severity: 'success', summary: 'Uploaded' }); + + spectator.click('[data-testid="status-toast-close"]'); + spectator.detectChanges(); + + expect(spectator.query('[data-testid="status-toast-summary"]')).toBeNull(); + }); + + it('should ignore a detail line rather than growing to fit it', () => { + // A caller that passes one is using the wrong outlet. Dropping it keeps this toast the + // size it promises to be instead of quietly turning into the wide one. + raise({ severity: 'success', summary: 'Uploaded', detail: 'a paragraph nobody asked for' }); + + expect(spectator.query('[data-testid="status-toast-summary"]')?.textContent?.trim()).toBe( + 'Uploaded' + ); + expect(spectator.fixture.nativeElement.textContent).not.toContain('a paragraph'); + }); + + it('should show a spinner while a run is still going', () => { + // The in-flight half of the same story, so "Uploading…" and "Uploaded" are one surface + // rather than an indicator in the toolbar and a toast somewhere else. + raise({ severity: 'secondary', summary: 'Uploading…', icon: 'pi pi-spin pi-spinner' }); + + expect(spectator.query('.pi-spinner')).toBeTruthy(); + }); + + it('should size itself to its text rather than to a fixed width', () => { + // The complaint this exists to answer: one short word sat in a 350px slab. Asserted as a + // rule rather than a pixel count, which would pin the font metrics of whatever runs it. + raise({ severity: 'success', summary: 'Uploaded' }); + + expect(getComputedStyle(box() as Element).width).not.toBe('350px'); + }); + + it('should ignore messages that are not addressed to it', () => { + // A portlet provides ONE MessageService, and an outlet with no key renders every message + // on it — which showed each status twice, wide at the top and compact at the bottom. + messageService.add({ severity: 'success', summary: 'for the other outlet' }); + spectator.detectChanges(); + + expect(spectator.query('[data-testid="status-toast-summary"]')).toBeNull(); + }); + + it('should keep the markup the run label carries', () => { + // The toolbar's label bolds the action and its target. Interpolating would print the tags. + raise({ severity: 'info', summary: 'Applying Upload to demo.dotcms.com' }); + + expect(spectator.query('[data-testid="status-toast-summary"] b')?.textContent).toBe( + 'Upload' + ); + }); +}); diff --git a/core-web/libs/ui/src/lib/components/dot-status-toast/dot-status-toast.component.ts b/core-web/libs/ui/src/lib/components/dot-status-toast/dot-status-toast.component.ts new file mode 100644 index 000000000000..67b0f7df70d9 --- /dev/null +++ b/core-web/libs/ui/src/lib/components/dot-status-toast/dot-status-toast.component.ts @@ -0,0 +1,61 @@ +import { ChangeDetectionStrategy, Component, inject, input } from '@angular/core'; + +import { MessageService } from 'primeng/api'; +import { ToastModule } from 'primeng/toast'; +import type { ToastPositionType } from 'primeng/types/toast'; + +import { DotMessagePipe } from '../../dot-message/dot-message.pipe'; +import { DotSeverityIconComponent } from '../dot-severity-icon/dot-severity-icon.component'; + +/** The key this outlet claims by default, so callers and the template cannot drift apart. */ +export const STATUS_TOAST_KEY = 'dot-status'; + +/** + * Toast outlet for a run's status: an icon, one short line, and a dismiss. + * + * Separate from {@link DotToastComponent} because the two carry different things, not because the + * styling differs. That one is a report — a title plus a detail paragraph, often several lines, + * frequently carrying markup — and it is 350px wide because it has to be. This one states an + * outcome in a couple of words, so it is sized to its text and drops `detail` entirely rather than + * growing to fit something a caller should not have sent here. + * + * Colour comes from Lara through the dotCMS preset, keyed on the message severity. The prototype + * this follows used a dark pill; that is deliberately not reproduced, because a black surface is + * not a pattern in this design system. + */ +@Component({ + selector: 'dot-status-toast', + imports: [ToastModule, DotSeverityIconComponent, DotMessagePipe], + templateUrl: './dot-status-toast.component.html', + styleUrl: './dot-status-toast.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush +}) +export class DotStatusToastComponent { + readonly #messageService = inject(MessageService); + + /** Where the stack renders, mirroring `p-toast`'s own positions. */ + $position = input('bottom-center', { alias: 'position' }); + + /** + * Which messages this outlet claims. + * + * Not optional in practice: a portlet provides ONE `MessageService`, and an outlet with no key + * renders every message on it — so sharing a shell with `dot-toast` showed each status twice, + * once wide at the top and once compact at the bottom. Callers add with this key to reach this + * outlet and no other. + */ + $key = input(STATUS_TOAST_KEY, { alias: 'key' }); + + /** + * Dismisses one message. + * + * The template supplies its own close button rather than PrimeNG's, so that it sits inside the + * compact row instead of the corner of a box this outlet does not draw. + * + * Clears by key rather than by message: this outlet shows one status at a time, so there is + * nothing to pick between. + */ + protected onClose(): void { + this.#messageService.clear(this.$key()); + } +} diff --git a/dotCMS/src/main/webapp/WEB-INF/messages/Language.properties b/dotCMS/src/main/webapp/WEB-INF/messages/Language.properties index 3b4d0b20d228..0e46762b08e2 100644 --- a/dotCMS/src/main/webapp/WEB-INF/messages/Language.properties +++ b/dotCMS/src/main/webapp/WEB-INF/messages/Language.properties @@ -7358,10 +7358,9 @@ content-drive.upload.toast.already-uploaded=These {0} files were already content-drive.upload.toast.already-uploaded-again=This batch was uploaded before, and {0} file(s) have now been uploaded again. This folder holds two copies of each, so delete the ones you do not want. # The handle: the batch is now the server's, and the author is released from it. The only # in-flight fact this flow announces, because it is a rule changing rather than work happening. -content-drive.upload.toast.backgrounded=Uploading in the background -content-drive.upload.toast.backgrounded-detail={0} file(s) are on their way. You can keep working, or leave this page. We will let you know when it is done. # The indicator's words for the server phase. "Applying X to Y" would describe an operation the # author has to wait for, which is the opposite of what they were just told. +content-drive.upload.indicator=Uploading {1} file(s) to {0}… content-drive.upload.indicator.background=Uploading {1} file(s) to {0} in the background… # Refused submissions. The endpoint keeps its two ceilings distinguishable by status, so these # stay two sentences: "fewer files" and "smaller files" are different instructions. From dad2523ce6aaeeaa43f63b06b69307bf4427ac7f Mon Sep 17 00:00:00 2001 From: Jalinson Diaz Date: Wed, 16 Sep 2026 11:38:33 -0300 Subject: [PATCH 29/44] fix(content-drive): stop System Host and the site root fighting over the selection (#37426) Opening System Host showed it selected AND the site root selected, and the location then snapped back to the site root on its own. Both come from the sidebar treating every location as a folder path. There are three kinds, and only one of them is: all site content is the absence of a location, System Host is a host rather than a folder, and a folder path is a folder path. The hierarchy load took the location literally, so it resolved System Host as `/SYSTEM_HOST/` -- a folder nobody has -- and when that came back empty it fell back to selecting the site row. Two entries then looked selected at once, and because the shell derives the location FROM the selected node, that site row wrote the location back to the site root and pushed the user out of System Host. Which of the two effects ran first decided whether it happened, so it came and went. The load now resolves only a folder location and leaves the tree unselected for the other two, which also drops a request that could only ever return nothing. The same confusion, mirrored, in the sync that keeps the tree in step with the location: the tree marks its site row with an EMPTY path while the site root as a location is `/`, a translation the shell already makes in the other direction. Looking `/` up literally matched no node, so the sync read the site root as "nowhere" and cleared the selection every time the user stood on it -- which is the second half of what looked like a single flickering bug. Co-Authored-By: Claude Opus 5 (1M context) --- .../features/sidebar/withSidebar.spec.ts | 88 ++++++++++++++++++- .../lib/store/features/sidebar/withSidebar.ts | 19 +++- 2 files changed, 104 insertions(+), 3 deletions(-) diff --git a/core-web/libs/portlets/dot-content-drive/portlet/src/lib/store/features/sidebar/withSidebar.spec.ts b/core-web/libs/portlets/dot-content-drive/portlet/src/lib/store/features/sidebar/withSidebar.spec.ts index 33bf99965a3e..427231e741c8 100644 --- a/core-web/libs/portlets/dot-content-drive/portlet/src/lib/store/features/sidebar/withSidebar.spec.ts +++ b/core-web/libs/portlets/dot-content-drive/portlet/src/lib/store/features/sidebar/withSidebar.spec.ts @@ -10,7 +10,7 @@ import { createFakeFolderSearchView, createFakeSite } from '@dotcms/utils-testin import { withSidebar } from './withSidebar'; -import { SYSTEM_HOST } from '../../../shared/constants'; +import { ROOT_PATH, SYSTEM_HOST, SYSTEM_HOST_PATH } from '../../../shared/constants'; import { DotContentDriveSortOrder, DotContentDriveState, @@ -548,3 +548,89 @@ describe('withSidebar - undefined path scenarios', () => { })); }); }); + +describe('withSidebar - a location that is not a folder', () => { + let spectator: SpectatorService>; + let store: InstanceType; + let folderService: Mocked; + + // System Host belongs to no site, so it is nowhere in this site's hierarchy. It reaches the + // sidebar as a location like any other, which is what made it look like a folder path. + const systemHostLocationStoreMock = signalStore( + withState({ + ...initialState, + path: SYSTEM_HOST_PATH + }), + withSidebar() + ); + + const createService = createServiceFactory({ + service: systemHostLocationStoreMock, + providers: [ + mockProvider(DotFolderService, { + searchFolders: vi.fn().mockReturnValue(searchResult([])) + }) + ] + }); + + beforeEach(() => { + spectator = createService(); + store = spectator.service; + folderService = spectator.inject(DotFolderService); + }); + + it('should not go looking for it among the site folders', () => { + // The site's own root level is still fetched — the tree shows this site's folders whatever + // location is open. What must not happen is resolving the location itself as a folder: it + // was turned into `/SYSTEM_HOST/` and queried, a folder nobody has, so the request could + // only ever come back empty. + expect(folderService.searchFolders).not.toHaveBeenCalledWith( + expect.objectContaining({ path: '/SYSTEM_HOST/' }) + ); + }); + + it('should leave the tree with nothing selected', () => { + // The failing behaviour, and the one the user sees: the hierarchy load fell back to the + // site row, so the site root and System Host both looked selected at once. Worse, the + // shell syncs the location from the selected node, so that row then rewrote the location + // to the site root and bounced the user straight back out of System Host. + expect(store.selectedNode()).toBeUndefined(); + }); +}); + +describe('withSidebar - the site root as a location', () => { + let spectator: SpectatorService>; + let store: InstanceType; + + const rootPathStoreMock = signalStore( + withState({ + ...initialState, + path: ROOT_PATH + }), + withSidebar() + ); + + const createService = createServiceFactory({ + service: rootPathStoreMock, + providers: [ + mockProvider(DotFolderService, { + searchFolders: vi.fn().mockReturnValue(searchResult([])) + }) + ] + }); + + beforeEach(() => { + spectator = createService(); + store = spectator.service; + }); + + it('should keep the site row selected once the location settles', () => { + // The tree marks its site row with an empty path, while the site root as a *location* is + // `/`. Looking the location up literally finds no node, so the sync that keeps the tree in + // step with the location read that as "nothing here" and cleared the selection every time + // the user was at the site root. + spectator.flushEffects(); + + expect(store.selectedNode()?.data?.id).toBe(mockSite.identifier); + }); +}); diff --git a/core-web/libs/portlets/dot-content-drive/portlet/src/lib/store/features/sidebar/withSidebar.ts b/core-web/libs/portlets/dot-content-drive/portlet/src/lib/store/features/sidebar/withSidebar.ts index 269b34267453..9e71b1fa5e23 100644 --- a/core-web/libs/portlets/dot-content-drive/portlet/src/lib/store/features/sidebar/withSidebar.ts +++ b/core-web/libs/portlets/dot-content-drive/portlet/src/lib/store/features/sidebar/withSidebar.ts @@ -78,7 +78,17 @@ export function withSidebar() { } const siteNode = createSiteNode(currentSite); - const urlFolderPath = store.path() || ''; + + // Only a folder path names a place inside this site's hierarchy. The other + // two locations do not: all site content is the absence of one, and System + // Host is a host rather than a folder. Both were resolved as folder paths + // anyway, so System Host was queried as `/SYSTEM_HOST/` — a folder nobody + // has — and the empty result fell back to selecting the site row. That left + // the site root and System Host both looking selected, and since the shell + // syncs the location *from* the selected node, the site row then rewrote the + // location back to the site root and bounced the user out of System Host. + const location = store.path() || ''; + const urlFolderPath = location.startsWith(ROOT_PATH) ? location : ''; // Only the initial state used to set this, so every later cold load (a site // change) left the previous site's tree on screen while its replacement was @@ -252,8 +262,13 @@ export function withSidebar() { selectionSync = effect(() => { const path = store.path(); const folders = store.folders(); + // The tree marks its site row with an empty path, while the site root as a + // *location* is `/` — the same translation the shell makes in the other + // direction. Looking `/` up literally matches no node, so without this the + // sync read the site root as "nowhere" and cleared the selection every time + // the user was standing on it. const match = path?.startsWith(ROOT_PATH) - ? findNodeByPath(folders, path) + ? findNodeByPath(folders, path === ROOT_PATH ? '' : path) : undefined; // Only when it actually differs: a folder click already sets the node, and From 474f4038028f40cd83e75a0a437e2cf2be93ba58 Mon Sep 17 00:00:00 2001 From: Jalinson Diaz Date: Wed, 16 Sep 2026 11:45:05 -0300 Subject: [PATCH 30/44] fix(content-drive): reload the listing after an upload to System Host (#37426) Files uploaded to System Host arrived, and the grid they arrived in never refreshed. A finished run reloads only when the folders it changed include the one on screen, and both sides of that comparison were built the same wrong way: the switcher's hostname glued to the location. That works while the location is a path on that site. It is not one on System Host, which belongs to no site, so the listing described itself as `//demo.dotcms.comsystem_host` -- a reference to nothing -- while the batch, falling back to the same site for want of a chosen folder, called itself `//demo.dotcms.com`. Two references, neither naming where the files went, and no chance of matching. `browsedFolderRef` now answers "which folder is on screen" for all three kinds of location instead of assuming the first, and the three places that asked the old way go through it: the reload gate, the sentence that names what a refusing folder accepts, and the affected folder a workflow action reports. An accepted batch with no folder of its own now records the browsed reference rather than rebuilding one from the switcher. It is the same destination the upload itself resolves through `$newContentHostId()`, so the run and the listing can no longer disagree about where a batch landed. Co-Authored-By: Claude Opus 5 (1M context) --- .../dot-content-drive-shell.component.spec.ts | 31 +++++++++++++++++++ .../dot-content-drive-shell.component.ts | 28 ++++++++++++----- .../action-execution/withActionExecution.ts | 4 +-- .../portlet/src/lib/utils/functions.ts | 20 ++++++++++++ 4 files changed, 73 insertions(+), 10 deletions(-) diff --git a/core-web/libs/portlets/dot-content-drive/portlet/src/lib/dot-content-drive-shell/dot-content-drive-shell.component.spec.ts b/core-web/libs/portlets/dot-content-drive/portlet/src/lib/dot-content-drive-shell/dot-content-drive-shell.component.spec.ts index 0dcc561073a1..7ff1181799c6 100644 --- a/core-web/libs/portlets/dot-content-drive/portlet/src/lib/dot-content-drive-shell/dot-content-drive-shell.component.spec.ts +++ b/core-web/libs/portlets/dot-content-drive/portlet/src/lib/dot-content-drive-shell/dot-content-drive-shell.component.spec.ts @@ -71,6 +71,7 @@ import { ERROR_MESSAGE_LIFE, SUCCESS_MESSAGE_LIFE, SYSTEM_HOST, + SYSTEM_HOST_PATH, WARNING_MESSAGE_LIFE, MOVE_TO_FOLDER_WORKFLOW_ACTION_ID } from '../shared/constants'; @@ -2168,6 +2169,36 @@ describe('DotContentDriveShellComponent', () => { ); }); + it('should remember a System Host batch as landing on System Host', () => { + // The listing reloads only when the run's folders include the one on screen, and both + // sides of that comparison were computed from the switcher's site plus the location. + // On System Host that gave `//demo.dotcms.com` for the batch and + // `//demo.dotcms.comsystem_host` for the listing — two references to nothing alike, so + // an upload finished and the grid it landed in never refreshed. + store.currentSite.mockReturnValue(MOCK_SITES[0]); + store.$systemHostSelected.mockReturnValue(true); + store.path.mockReturnValue(SYSTEM_HOST_PATH); + uploadService.uploadFilesByBaseType.mockReturnValue( + of({ + kind: 'accepted', + handle: { jobId: 'job-sh', statusUrl: '/api/v1/jobs/job-sh/status' } + }) + ); + + selectUploadType({ + targetFolder: undefined, + files: createFileList([createFile('a.png')]), + baseType: 'DOTASSET' + }); + + expect(store.trackUploadJob).toHaveBeenCalledWith( + 'job-sh', + [`//${SYSTEM_HOST.identifier}`.toLowerCase()], + expect.any(String), + 'DOTASSET' + ); + }); + it('should submit a single file down the same path, as a batch of one', () => { // Not a special case. One path, one set of gates, one method: a lone file is a batch // whose length is one, so nothing forks on count. diff --git a/core-web/libs/portlets/dot-content-drive/portlet/src/lib/dot-content-drive-shell/dot-content-drive-shell.component.ts b/core-web/libs/portlets/dot-content-drive/portlet/src/lib/dot-content-drive-shell/dot-content-drive-shell.component.ts index 1d5f7c7a8958..0e359280d85d 100644 --- a/core-web/libs/portlets/dot-content-drive/portlet/src/lib/dot-content-drive-shell/dot-content-drive-shell.component.ts +++ b/core-web/libs/portlets/dot-content-drive/portlet/src/lib/dot-content-drive-shell/dot-content-drive-shell.component.ts @@ -109,6 +109,7 @@ import { canAddChildrenTo, encodeFilters, isFolder, + browsedFolderRef, normalizeFolderRef, toFolderRef } from '../utils/functions'; @@ -667,7 +668,7 @@ export class DotContentDriveShellComponent implements OnDestroy { !affectedFolders?.length || affectedFolders .map(normalizeFolderRef) - .includes(toFolderRef(this.#store.currentSite()?.hostname, this.#store.path())); + .includes(browsedFolderRef(this.#store.currentSite()?.hostname, this.#store.path())); /** * Reports a finished workflow action as a toast, refreshes the grid, and closes the dialog if it @@ -788,7 +789,7 @@ export class DotContentDriveShellComponent implements OnDestroy { const refusingFolderIsOnScreen = affectedRefs.length === 1 && affectedRefs[0] === - toFolderRef(this.#store.currentSite()?.hostname, this.#store.path()); + browsedFolderRef(this.#store.currentSite()?.hostname, this.#store.path()); // Narrowed the same way the upload itself narrows the selection: the tree's load-more row // is a node without a folder behind it, so it carries no filter to name. @@ -1642,12 +1643,23 @@ export class DotContentDriveShellComponent implements OnDestroy { this.#store.trackUploadJob( event.handle.jobId, [ - toFolderRef( - hostFolder?.hostname ?? this.#store.currentSite()?.hostname, - // Same reason: an empty path is the site root, which normalises to - // `//hostname` — the ref the listing computes when browsing it. - hostFolder?.path || '/' - ) + hostFolder?.hostname + ? toFolderRef( + hostFolder.hostname, + // An empty path is the site root, which normalises to + // `//hostname` — the ref the listing computes when browsing it. + hostFolder.path || ROOT_PATH + ) + : // No folder chosen means the batch lands wherever the sidebar is + // pointing, which is exactly what the browsed reference describes. + // Rebuilding it from the switcher's site instead named the site + // root while the files were going to System Host, so the run and + // the listing disagreed about where they had landed and the grid + // was never refreshed. + browsedFolderRef( + this.#store.currentSite()?.hostname, + this.#store.path() + ) ], backgroundRunId, // Carried to the outcome because a resubmission means opposite things by diff --git a/core-web/libs/portlets/dot-content-drive/portlet/src/lib/store/features/action-execution/withActionExecution.ts b/core-web/libs/portlets/dot-content-drive/portlet/src/lib/store/features/action-execution/withActionExecution.ts index bf8c2b29e140..14c54e131e24 100644 --- a/core-web/libs/portlets/dot-content-drive/portlet/src/lib/store/features/action-execution/withActionExecution.ts +++ b/core-web/libs/portlets/dot-content-drive/portlet/src/lib/store/features/action-execution/withActionExecution.ts @@ -41,7 +41,7 @@ import { DotContentDriveUploadJob, DotContentDriveState } from '../../../shared/models'; -import { normalizeFolderRef, toFolderRef } from '../../../utils/functions'; +import { browsedFolderRef, normalizeFolderRef } from '../../../utils/functions'; interface WithActionExecutionState { /** @@ -619,7 +619,7 @@ export function withActionExecution() { // A move changes two folders: the one the rows leave and the one they // arrive in. Every other workflow action changes rows where they already // are, so the browsed folder is the only one affected. - const browsedFolder = toFolderRef( + const browsedFolder = browsedFolderRef( store.currentSite()?.hostname, store.path() ); diff --git a/core-web/libs/portlets/dot-content-drive/portlet/src/lib/utils/functions.ts b/core-web/libs/portlets/dot-content-drive/portlet/src/lib/utils/functions.ts index 6fcaff1e5eb7..1cb058c76056 100644 --- a/core-web/libs/portlets/dot-content-drive/portlet/src/lib/utils/functions.ts +++ b/core-web/libs/portlets/dot-content-drive/portlet/src/lib/utils/functions.ts @@ -26,6 +26,7 @@ import { ROOT_PATH, SHARED_ASSETS_ENABLED_VALUE, SHARED_ASSETS_FILTER_KEY, + SYSTEM_HOST, SYSTEM_HOST_PATH, USER_SEARCHABLE_PREFIX } from '../shared/constants'; @@ -956,3 +957,22 @@ export const toFolderRef = (hostname: string | null | undefined, path: string | /** Normalises an already-formed `//hostname/path` reference. See {@link toFolderRef}. */ export const normalizeFolderRef = (ref: string | null | undefined): string => (ref ?? '').toLowerCase().replace(/\/+$/, ''); + +/** + * The folder reference for the location the drive is on, in the form runs are compared against. + * + * Not {@link toFolderRef} applied to the site and the location directly, because the location is + * not always a path on the browsed site. System Host belongs to no site, so pairing its reserved + * location value with whatever hostname the switcher happens to show produced + * `//demo.dotcms.comsystem_host` — a reference to nothing, which matched no run's affected folders. + * The listing therefore never reloaded after an upload landed there. + * + * @param {string | null | undefined} hostname - The browsed site's hostname + * @param {string | null | undefined} path - The location, which may not be a folder path at all + * @returns {string} the canonical reference for what is on screen + */ +export const browsedFolderRef = ( + hostname: string | null | undefined, + path: string | null | undefined +): string => + path === SYSTEM_HOST_PATH ? toFolderRef(SYSTEM_HOST.hostname, ROOT_PATH) : toFolderRef(hostname, path); From 9af0608f0c4e4d31d50ded58a4737446f0f022ab Mon Sep 17 00:00:00 2001 From: Jalinson Diaz Date: Wed, 16 Sep 2026 12:25:25 -0300 Subject: [PATCH 31/44] style(content-drive): format browsedFolderRef (#37426) The formatting gate CI runs is workspace-wide, and this line was over the width. Co-Authored-By: Claude Opus 5 (1M context) --- .../dot-content-drive/portlet/src/lib/utils/functions.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/core-web/libs/portlets/dot-content-drive/portlet/src/lib/utils/functions.ts b/core-web/libs/portlets/dot-content-drive/portlet/src/lib/utils/functions.ts index 1cb058c76056..ebe28c45f28d 100644 --- a/core-web/libs/portlets/dot-content-drive/portlet/src/lib/utils/functions.ts +++ b/core-web/libs/portlets/dot-content-drive/portlet/src/lib/utils/functions.ts @@ -975,4 +975,6 @@ export const browsedFolderRef = ( hostname: string | null | undefined, path: string | null | undefined ): string => - path === SYSTEM_HOST_PATH ? toFolderRef(SYSTEM_HOST.hostname, ROOT_PATH) : toFolderRef(hostname, path); + path === SYSTEM_HOST_PATH + ? toFolderRef(SYSTEM_HOST.hostname, ROOT_PATH) + : toFolderRef(hostname, path); From b8df227962c4a7fea107186878b6403812d28ed8 Mon Sep 17 00:00:00 2001 From: Jalinson Diaz Date: Wed, 16 Sep 2026 12:37:30 -0300 Subject: [PATCH 32/44] fix(content-drive): ignore unknown fields on the drive request form (#37426) A binary rolled back to a build that predates `browseScope` would refuse every browse request carrying it. The field is not occasional traffic. `toRequestLocation` sends it for all site content, the site root and System Host, so it rides on the view the drive opens on. A browser holding a cached bundle keeps sending it after the server it talks to has been rolled back, and the older binary answers with a deserialization failure rather than ignoring a field it does not know. Browsing then stays broken until the user hard-refreshes, which nothing tells them to do. The form carried no `@JsonIgnoreProperties(ignoreUnknown = true)`, and `DotObjectMapperProvider.createDefaultMapper` disables only `WRAP_EXCEPTIONS`, leaving `FAIL_ON_UNKNOWN_PROPERTIES` at Jackson's enabled default. So the refusal was the documented behaviour of both halves together, not an accident. The trade is real and worth naming: unknown fields are now ignored rather than refused, so a caller who misspells one gets silence instead of an error naming their typo. `A11yAgentStopForm`, `PageScanCheckForm` and `FileUploadDetail` make the same trade for the same reason, and `A11yAgentStopForm` has the identical shape -- an Immutables interface deserialized through a generated class -- which is what shows the annotation survives the generation. Raised by the automatic review on PR #37487 and confirmed by reading both the form and the mapper before acting on it. Co-Authored-By: Claude Opus 5 (1M context) --- .../v1/drive/AbstractDriveRequestForm.java | 2 + .../DriveRequestFormBrowseScopeTest.java | 39 +++++++++++++++++++ 2 files changed, 41 insertions(+) diff --git a/dotCMS/src/main/java/com/dotcms/rest/api/v1/drive/AbstractDriveRequestForm.java b/dotCMS/src/main/java/com/dotcms/rest/api/v1/drive/AbstractDriveRequestForm.java index 9f69aea5dd9f..bca0cca0ec25 100644 --- a/dotCMS/src/main/java/com/dotcms/rest/api/v1/drive/AbstractDriveRequestForm.java +++ b/dotCMS/src/main/java/com/dotcms/rest/api/v1/drive/AbstractDriveRequestForm.java @@ -4,6 +4,7 @@ import com.dotmarketing.business.APILocator; import com.dotmarketing.util.HostUtil; import com.liferay.util.StringPool; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.annotation.JsonProperty; import com.fasterxml.jackson.databind.annotation.JsonDeserialize; import com.fasterxml.jackson.databind.annotation.JsonSerialize; @@ -75,6 +76,7 @@ @Value.Immutable @JsonSerialize(as = DriveRequestForm.class) @JsonDeserialize(as = DriveRequestForm.class) +@JsonIgnoreProperties(ignoreUnknown = true) public interface AbstractDriveRequestForm { /** diff --git a/dotCMS/src/test/java/com/dotcms/rest/api/v1/drive/DriveRequestFormBrowseScopeTest.java b/dotCMS/src/test/java/com/dotcms/rest/api/v1/drive/DriveRequestFormBrowseScopeTest.java index b28678f47726..db88b901de56 100644 --- a/dotCMS/src/test/java/com/dotcms/rest/api/v1/drive/DriveRequestFormBrowseScopeTest.java +++ b/dotCMS/src/test/java/com/dotcms/rest/api/v1/drive/DriveRequestFormBrowseScopeTest.java @@ -1,8 +1,10 @@ package com.dotcms.rest.api.v1.drive; +import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertTrue; import static org.junit.Assert.assertThrows; +import com.dotcms.rest.api.v1.DotObjectMapperProvider; import java.util.List; import javax.ws.rs.WebApplicationException; import org.junit.Test; @@ -105,4 +107,41 @@ public void testEveryBrowseScopeIsAcceptedAtTheSiteRoot() { public void testAFolderPathWithNoBrowseScopeIsUntouched() { formFor(A_FOLDER).build(); } + + /** + * Given a body carrying a field this binary does not know, When it is deserialized, Then it is + * accepted and the unknown field ignored. + * + *

This is the rollback direction of the same compatibility the rest of this class pins going + * forward. A browser holding a cached bundle keeps sending {@code browseScope} after the server + * it talks to has been rolled back to a build that predates the field, and that is not an edge + * case: the field is sent for all site content, the site root and System Host, so it rides on + * the view the drive opens on. Without this the older binary answers every one of those with a + * deserialization failure, and browsing stays broken until the user hard-refreshes -- which + * they have no way of knowing to do.

+ * + *

The trade being made is real: unknown fields are now ignored rather than refused, so a + * caller who misspells one gets silence instead of an error naming their typo. The peer forms + * ({@code A11yAgentStopForm}, {@code PageScanCheckForm}, {@code FileUploadDetail}) make the same + * trade, for the same reason.

+ * + *

Deserialized through the mapper the resource actually uses, not a plain one. A plain + * {@code ObjectMapper} was tried first and fails before reaching the assertion: this form holds + * Guava collections, and the modules that read them are registered by + * {@code createDefaultMapper}. Standing the real one up is also what makes the test mean + * something -- it is that mapper's settings, not Jackson's defaults, that decide whether an + * unknown field is refused, and it leaves {@code FAIL_ON_UNKNOWN_PROPERTIES} at Jackson's + * enabled default.

+ */ + @Test + public void testAFieldThisBinaryDoesNotKnowIsIgnoredRatherThanRefused() throws Exception { + final String bodyFromANewerFrontend = "{\"assetPath\":\"" + SITE_ROOT + + "\",\"language\":[\"1\"],\"aFieldFromTheFuture\":\"ROOT\"}"; + + final DriveRequestForm form = DotObjectMapperProvider.createDefaultMapper() + .readValue(bodyFromANewerFrontend, DriveRequestForm.class); + + assertEquals("the fields this binary does know must still be read", + SITE_ROOT, form.assetPath()); + } } From 32180047761843ed5d1d0fbfb620d689f85ed194 Mon Sep 17 00:00:00 2001 From: Jalinson Diaz Date: Wed, 16 Sep 2026 13:32:06 -0300 Subject: [PATCH 33/44] fix(content-drive): no new folder on System Host, and a path that resolves (#37426) "New Folder" on System Host opened a dialog previewing the path `//demo.dotcms.comSYSTEM_HOST/`. Two faults, one cause. The dialog anchors a new folder on the location the drive is open on and pastes it after the hostname, but only one of the three locations is a folder path: all site content is the absence of one, and System Host is a reserved word, recognisable as not-a-path precisely because it does not begin with `/`. Concatenated anyway it produced a path resolving to nothing, which the dialog then showed the author as where their folder would land. It now anchors only on a location that is a path, which is the same test the rest of the feature applies. The deeper answer is that the entry should not be there at all. System Host lists no folders -- `listsFolders` answers false for that scope, and its sidebar row opens no tree -- so a folder created there could never be shown again by this portlet. Writing something the UI can never read back is a trap regardless of what the server would accept. The "New" menu drops the folder entry there and keeps the content types, because System Host holds content and adding some is why the scope accepts new items at all. All site content is deliberately untouched: its location is absent, so the builder already yields `//hostname/name` and a new folder lands at the site root, which is where that entry points for uploads too. Not verified against a running instance: whether dotCMS itself permits a folder on SYSTEM_HOST is unanswered, because the QA environment was unreachable. This is a decision about Content Drive, not about the platform, and the path builder is right either way. Co-Authored-By: Claude Opus 5 (1M context) --- ...tent-drive-dialog-folder.component.spec.ts | 13 +++++- ...t-content-drive-dialog-folder.component.ts | 13 +++++- ...ot-content-drive-toolbar.component.spec.ts | 41 +++++++++++++++++ .../dot-content-drive-toolbar.component.ts | 44 ++++++++++++++----- 4 files changed, 96 insertions(+), 15 deletions(-) diff --git a/core-web/libs/portlets/dot-content-drive/portlet/src/lib/components/dialogs/dot-content-drive-dialog-folder/dot-content-drive-dialog-folder.component.spec.ts b/core-web/libs/portlets/dot-content-drive/portlet/src/lib/components/dialogs/dot-content-drive-dialog-folder/dot-content-drive-dialog-folder.component.spec.ts index 2c4635b5019a..3fe4916c361d 100644 --- a/core-web/libs/portlets/dot-content-drive/portlet/src/lib/components/dialogs/dot-content-drive-dialog-folder/dot-content-drive-dialog-folder.component.spec.ts +++ b/core-web/libs/portlets/dot-content-drive/portlet/src/lib/components/dialogs/dot-content-drive-dialog-folder/dot-content-drive-dialog-folder.component.spec.ts @@ -16,7 +16,7 @@ import { createFakeSite, MockDotMessageService } from '@dotcms/utils-testing'; import { DotContentDriveDialogFolderComponent } from './dot-content-drive-dialog-folder.component'; -import { DEFAULT_FILE_ASSET_TYPES } from '../../../shared/constants'; +import { DEFAULT_FILE_ASSET_TYPES, SYSTEM_HOST_PATH } from '../../../shared/constants'; import { DotContentDriveStore } from '../../../store/dot-content-drive.store'; const mockSite = createFakeSite({ @@ -230,6 +230,17 @@ describe('DotContentDriveDialogFolderComponent', () => { expect(component.$finalPath()).toBe('//demo.dotcms.com/'); }); + it('should not paste a location that is not a folder path into the preview', () => { + // System Host reaches the dialog as the location `SYSTEM_HOST`, which is a reserved + // word rather than a path — that is what tells it apart from a folder. Concatenated + // onto the hostname it produced `//demo.dotcms.comSYSTEM_HOST/`, a path that resolves + // to nothing, and the dialog showed it to the user as where their folder would land. + store.path.mockReturnValue(SYSTEM_HOST_PATH); + component.folderForm.get('name')?.setValue('new-folder'); + + expect(component.$finalPath()).toBe('//demo.dotcms.com/new-folder/'); + }); + it('should handle path with trailing slash', () => { store.path.mockReturnValue('/documents/'); component.folderForm.patchValue({ name: 'new-folder' }); diff --git a/core-web/libs/portlets/dot-content-drive/portlet/src/lib/components/dialogs/dot-content-drive-dialog-folder/dot-content-drive-dialog-folder.component.ts b/core-web/libs/portlets/dot-content-drive/portlet/src/lib/components/dialogs/dot-content-drive-dialog-folder/dot-content-drive-dialog-folder.component.ts index e8acfbca4bf9..39028cb35a7d 100644 --- a/core-web/libs/portlets/dot-content-drive/portlet/src/lib/components/dialogs/dot-content-drive-dialog-folder/dot-content-drive-dialog-folder.component.ts +++ b/core-web/libs/portlets/dot-content-drive/portlet/src/lib/components/dialogs/dot-content-drive-dialog-folder/dot-content-drive-dialog-folder.component.ts @@ -36,7 +36,8 @@ import { DotFieldRequiredDirective, DotMessagePipe } from '@dotcms/ui'; import { SUGGESTED_ALLOWED_FILE_EXTENSIONS, DEFAULT_FILE_ASSET_TYPES, - FOLDER_UPLOAD_BEHAVIOR_OPTIONS + FOLDER_UPLOAD_BEHAVIOR_OPTIONS, + ROOT_PATH } from '../../../shared/constants'; import { DotContentDriveStore } from '../../../store/dot-content-drive.store'; interface FolderForm { @@ -436,13 +437,21 @@ export class DotContentDriveDialogFolderComponent { * different folder entirely — saving would 404, or silently overwrite a same-named folder under * the open one. * + * Only a location that *is* a folder path anchors anything. The sidebar can select two things + * that are not: all site content, which is the absence of a location, and System Host, a + * reserved word that can never be mistaken for a path precisely because it does not start with + * `/`. Pasted onto the hostname the reserved word produced `//demo.dotcms.comSYSTEM_HOST/`, a + * path resolving to nothing, and the dialog showed it as where the folder would land. + * * @returns {string} The parent path, e.g. `/application/blog` or `''` at the site root */ #getParentPath(): string { const folder = this.$folder(); if (!folder) { - return this.#store.path()?.replace(/\/$/, '') ?? ''; + const location = this.#store.path() ?? ''; + + return location.startsWith(ROOT_PATH) ? location.replace(/\/$/, '') : ''; } const withoutTrailingSlash = folder.path.replace(/\/$/, ''); diff --git a/core-web/libs/portlets/dot-content-drive/portlet/src/lib/components/dot-content-drive-toolbar/dot-content-drive-toolbar.component.spec.ts b/core-web/libs/portlets/dot-content-drive/portlet/src/lib/components/dot-content-drive-toolbar/dot-content-drive-toolbar.component.spec.ts index 79c3c8697fcb..f17ea6bfff4a 100644 --- a/core-web/libs/portlets/dot-content-drive/portlet/src/lib/components/dot-content-drive-toolbar/dot-content-drive-toolbar.component.spec.ts +++ b/core-web/libs/portlets/dot-content-drive/portlet/src/lib/components/dot-content-drive-toolbar/dot-content-drive-toolbar.component.spec.ts @@ -69,6 +69,7 @@ describe('DotContentDriveToolbarComponent', () => { // Real signals so the component's computeds re-run when they change const isTreeExpandedSignal = signal(false); const allSiteContentSelectedSignal = signal(false); + const systemHostSelectedSignal = signal(false); // The toolbar reports a run by raising a status message rather than drawing it, so the spec owns // the service it pushes through. const messageServiceSpy = { add: vi.fn(), clear: vi.fn() }; @@ -145,6 +146,7 @@ describe('DotContentDriveToolbarComponent', () => { toolbarRunCount: activeRunCountSignal, siteCanAddChildren: siteCanAddChildrenSignal, $allSiteContentSelected: allSiteContentSelectedSignal, + $systemHostSelected: systemHostSelectedSignal, // Mirrors the store's own computed so the toolbar tests still drive the gate // through the signals it derives from, not through a hardcoded answer. $canAddChildren: computed(() => { @@ -1158,4 +1160,43 @@ describe('DotContentDriveToolbarComponent', () => { expect(spectator.component.$actionExecutionPercent()).toBe(25); }); }); + + describe('the New menu on System Host', () => { + const folderEntry = () => + spectator.component + .$items() + .find((item) => item.label === 'content-drive.add-new.context-menu.folder'); + + it('should not offer a new folder there', () => { + // System Host lists no folders: `listsFolders` returns false for that scope and the + // sidebar row opens no tree beneath it. A folder created there could never be shown + // again by this portlet, and the dialog could not even name where it would land -- the + // location is a reserved word, so pasting it after the hostname gave + // `//demo.dotcms.comSYSTEM_HOST/`. + systemHostSelectedSignal.set(true); + spectator.detectChanges(); + + expect(folderEntry()).toBeUndefined(); + }); + + it('should still offer content types there', () => { + // The other half of the rule: System Host holds content, and adding some is the whole + // reason the scope accepts new items at all. Only the folder entry goes. + systemHostSelectedSignal.set(true); + spectator.detectChanges(); + + expect( + spectator.component + .$items() + .some((item) => item.label === 'content-drive.add-new.all-content-types') + ).toBe(true); + }); + + it('should offer a new folder everywhere else', () => { + systemHostSelectedSignal.set(false); + spectator.detectChanges(); + + expect(folderEntry()).toBeDefined(); + }); + }); }); diff --git a/core-web/libs/portlets/dot-content-drive/portlet/src/lib/components/dot-content-drive-toolbar/dot-content-drive-toolbar.component.ts b/core-web/libs/portlets/dot-content-drive/portlet/src/lib/components/dot-content-drive-toolbar/dot-content-drive-toolbar.component.ts index 86b335753a63..7e20a1f21446 100644 --- a/core-web/libs/portlets/dot-content-drive/portlet/src/lib/components/dot-content-drive-toolbar/dot-content-drive-toolbar.component.ts +++ b/core-web/libs/portlets/dot-content-drive/portlet/src/lib/components/dot-content-drive-toolbar/dot-content-drive-toolbar.component.ts @@ -258,7 +258,19 @@ export class DotContentDriveToolbarComponent { : null; }); - readonly $items = signal([ + /** + * What the "New" menu offers, which depends on where the user is. + * + * A computed rather than a fixed list because System Host takes one entry away. It lists no + * folders -- `listsFolders` answers false for that scope, and its sidebar row opens no tree -- + * so a folder created there could never be shown again by this portlet. The dialog could not + * even name where it would land: the location is a reserved word, so the path preview read + * `//demo.dotcms.comSYSTEM_HOST/`. + * + * Content types stay. System Host holds content, and adding some is the whole reason the scope + * accepts new items at all. + */ + readonly $items = computed(() => [ { label: this.#dotMessageService.get('content-drive.add-new.all-content-types'), icon: 'grid_view', @@ -270,17 +282,25 @@ export class DotContentDriveToolbarComponent { icon: option.icon, command: () => this.#openContentTypeSelector(option.listType) })), - { separator: true }, - { - label: this.#dotMessageService.get('content-drive.add-new.context-menu.folder'), - icon: 'folder', - command: () => { - this.#store.setDialog({ - type: DIALOG_TYPE.FOLDER, - header: this.#dotMessageService.get('content-drive.dialog.folder.header') - }); - } - } + ...(this.#store.$systemHostSelected() + ? [] + : [ + { separator: true }, + { + label: this.#dotMessageService.get( + 'content-drive.add-new.context-menu.folder' + ), + icon: 'folder', + command: () => { + this.#store.setDialog({ + type: DIALOG_TYPE.FOLDER, + header: this.#dotMessageService.get( + 'content-drive.dialog.folder.header' + ) + }); + } + } + ]) ]); /** From daac5e4f1c5b1a974078e65303d85e0e6726602a Mon Sep 17 00:00:00 2001 From: Jalinson Diaz Date: Wed, 16 Sep 2026 14:18:04 -0300 Subject: [PATCH 34/44] fix(content-drive): point the e2e at the toast, and drop the unreachable run copy (#37426) The bulk-upload e2e failed on every attempt waiting for `action-execution-indicator`, a testid deleted when the status toast replaced the toolbar's in-flight indicator. Nothing swept the e2e for it, so the page object kept waiting for markup that no longer exists. `expectHandedToBackground` now asserts the status toast carries the backgrounded wording, which is the single surface saying both halves since the duplicate advisory was removed. The "Applying X to Y" fallback is deleted, and the two message keys with it. Nothing could reach it: only an unmarked run arrives at this surface (`toolbarRun` filters to `targets.length === 0`, because a run whose rows are marked in the grid already shows the author where it is), and every unmarked run is an upload, which names itself. Its one real effect was on uploads before they had their own words, where it produced "Applying Upload to demo.dotcms.com" -- a sentence for an action performed ON content rather than for files going INTO a place. A run arriving with no copy now says nothing, and the effect raises no toast rather than an empty pill. Nine fixtures in the toolbar spec built runs as `{ actionName: 'Publish', total: 3 }`: no label key, no targets. No such run exists, and they are why the dead branch read as live. They are uploads now, which is the only thing that reaches that surface. The escaping test moves to the surviving path, where a target label is still interpolated into a message bound with `[innerHTML]`. Five e2e tests for this session's work: the hierarchy stays unselected on System Host across a reload and the location does not bounce back to the site root; no New Folder there while content types remain; the folder dialog names a path that resolves; an upload landing on System Host refreshes the listing with no reload of the test's own; and a run in flight reports and then stops. The last matters because the toast is raised sticky, which makes ending it the store's job rather than the toast library's. `expectNothingSelected` is scoped to `hierarchy-scroll`, not to the sidebar: the two standalone entries reuse `p-tree-node-selected` so they match the rows they sit around, and counting from the panel would read a selected System Host button as a selected tree row. Co-Authored-By: Claude Opus 5 (1M context) --- .../src/pages/contentDrive.page.ts | 61 +++++- .../content-drive-browse-scopes.spec.ts | 112 ++++++++++++ .../helpers/content-drive-tree.ts | 21 +++ ...ot-content-drive-toolbar.component.spec.ts | 173 ++++++++---------- .../dot-content-drive-toolbar.component.ts | 61 +++--- .../WEB-INF/messages/Language.properties | 2 - 6 files changed, 301 insertions(+), 129 deletions(-) diff --git a/core-web/apps/dotcms-ui-e2e/src/pages/contentDrive.page.ts b/core-web/apps/dotcms-ui-e2e/src/pages/contentDrive.page.ts index 9ea400de6dfb..c4be2ce5dfdf 100644 --- a/core-web/apps/dotcms-ui-e2e/src/pages/contentDrive.page.ts +++ b/core-web/apps/dotcms-ui-e2e/src/pages/contentDrive.page.ts @@ -31,8 +31,8 @@ export class ContentDrivePage { readonly allSiteContentRow: Locator; readonly systemHostRow: Locator; readonly searchField: Locator; - readonly uploadIndicator: Locator; - readonly uploadProgress: Locator; + readonly statusToast: Locator; + readonly statusToastSummary: Locator; readonly toasts: Locator; constructor(private page: Page) { @@ -51,9 +51,11 @@ export class ContentDrivePage { // `tree-node-label`, which is why `currentSiteHostname` above still finds the site row. this.allSiteContentRow = this.sidebar.getByTestId('all-site-content'); this.systemHostRow = this.sidebar.getByTestId('system-host'); - // The toolbar's in-flight indicator, and the position it shows when a run reports one. - this.uploadIndicator = page.getByTestId('action-execution-indicator'); - this.uploadProgress = page.getByTestId('action-execution-progress'); + // A run in flight is reported by the status toast, not by the toolbar. The toolbar used to + // draw an indicator at the end of the filter row (`action-execution-indicator`); that markup + // is gone, so anything still looking for it is asserting on a testid that cannot appear. + this.statusToast = page.getByTestId('dot-status-toast'); + this.statusToastSummary = page.getByTestId('status-toast-summary'); this.toasts = page.locator('.p-toast-message'); } @@ -297,10 +299,55 @@ export class ContentDrivePage { * learns the rules changed; without the indicator the batch looks finished when it is not. */ async expectHandedToBackground() { - await expect(this.toasts.filter({ hasText: 'in the background' }).first()).toBeVisible({ + // One surface says both halves now. The status toast carries the in-flight wording, which + // for a backgrounded batch is the sentence that tells the author the batch is theirs to + // leave — so its presence is the indicator, and there is no second element to check. + // It used to be two: a wide advisory toast plus the toolbar's indicator, which meant a + // backgrounded upload announced itself twice. + await expect(this.statusToastSummary.filter({ hasText: 'in the background' })).toBeVisible({ timeout: OUTCOME_TIMEOUT }); - await expect(this.uploadIndicator).toBeVisible(); + } + + /** What the status toast is saying right now, if anything. */ + async expectStatusToastContaining(text: string) { + await expect(this.statusToastSummary.filter({ hasText: text }).first()).toBeVisible({ + timeout: OUTCOME_TIMEOUT + }); + } + + /** + * The status toast has stopped reporting. + * + * A run that never clears its toast leaves the portlet claiming work is in flight forever, and + * the toast is sticky precisely so it cannot time itself out -- which makes it the store's job + * to end it, and therefore worth asserting. + */ + async expectStatusToastGone() { + await expect(this.statusToastSummary).toHaveCount(0, { timeout: OUTCOME_TIMEOUT }); + } + + /** Opens the New menu and returns the labels it offers. */ + async openNewMenu(): Promise { + await this.toolbar.getByTestId('add-new-button').click(); + const items = this.page.getByRole('menuitem'); + await expect(items.first()).toBeVisible({ timeout: 10000 }); + + return items.allInnerTexts(); + } + + /** + * The path the folder dialog says a new folder will land on. + * + * Read rather than asserted here because the wrong value is not a missing element: the builder + * used to paste a location that is not a folder path straight after the hostname, so the field + * was populated and confidently wrong. + */ + async folderDialogPath(): Promise { + const path = this.page.getByTestId('folder-path-preview'); + await expect(path).toBeVisible({ timeout: 10000 }); + + return (await path.innerText()).trim(); } /** A message the author can read, whatever severity it arrived with. */ diff --git a/core-web/apps/dotcms-ui-e2e/src/tests/content-drive/content-drive-browse-scopes.spec.ts b/core-web/apps/dotcms-ui-e2e/src/tests/content-drive/content-drive-browse-scopes.spec.ts index d8406f9c0928..e34472bdaea3 100644 --- a/core-web/apps/dotcms-ui-e2e/src/tests/content-drive/content-drive-browse-scopes.spec.ts +++ b/core-web/apps/dotcms-ui-e2e/src/tests/content-drive/content-drive-browse-scopes.spec.ts @@ -134,4 +134,116 @@ test.describe('Content Drive Browse Scopes', () => { await adminPage.reload(); await drive.expectSelectedEntry('system-host'); }); + + test('leaves the hierarchy unselected on System Host, and stays there @critical', async ({ + adminPage + }) => { + // Reported from the browser as "the root of the site selected and the system host + // selected". Two faults met here. The hierarchy load resolved the reserved location + // `SYSTEM_HOST` as the folder path `/SYSTEM_HOST/`, found nothing, and fell back to + // selecting the site row -- so two entries read as current. Then the shell, which derives + // the location *from* the selected node, took that site row and rewrote the location back + // to the site root, pushing the author out of the scope they had just chosen. + // + // A reload is the case that made it reliable: on a cold start the location is already + // System Host when the tree is built, so the fallback ran every time. + const drive = new ContentDrivePage(adminPage); + const tree = new ContentDriveTree(adminPage); + + await drive.goTo(); + await drive.selectSystemHost(); + await adminPage.reload(); + + await drive.expectSelectedEntry('system-host'); + await tree.expectNothingSelected(); + expect(adminPage.url()).toContain('SYSTEM_HOST'); + }); + + test('offers no new folder on System Host @critical', async ({ adminPage }) => { + // System Host lists no folders, and its entry opens no tree, so one created there could + // never be shown again by this portlet. The dialog could not even name where it would + // land: the location is a reserved word, and pasting it after the hostname produced + // `//demo.dotcms.comSYSTEM_HOST/`. + const drive = new ContentDrivePage(adminPage); + await drive.goTo(); + + await drive.selectSystemHost(); + const onSystemHost = await drive.openNewMenu(); + + expect(onSystemHost.join(' ')).not.toContain('Folder'); + // The other half of the rule: System Host holds content, and adding some is the whole + // reason the scope accepts new items at all. + expect(onSystemHost.join(' ')).toContain('Content'); + }); + + test('names a path that resolves when creating a folder @critical', async ({ + adminPage, + apiHelpers + }) => { + // The preview is built from the location the drive is open on, and only one of the three + // kinds of location is a folder path. Asserted as "starts with the site and carries no + // reserved word" rather than as an exact string, so it holds for whichever site the run + // lands on. + const site = await apiHelpers.getDefaultSite(); + const drive = new ContentDrivePage(adminPage); + await drive.goTo(); + + await drive.selectAllSiteContent(); + await drive.openNewMenu(); + await adminPage.getByRole('menuitem', { name: 'Folder' }).click(); + + const path = await drive.folderDialogPath(); + + expect(path).not.toContain('SYSTEM_HOST'); + expect(path).toBe(`//${site.hostname}/`); + }); + + test('refreshes the listing after an upload lands on System Host @critical', async ({ + adminPage, + testSuffix + }) => { + // The grid reloads only when the finished run names the folder on screen, and both sides + // of that comparison were built from the switcher's site glued to the location. On System + // Host that gave `//demo.dotcms.comSYSTEM_HOST` for the listing and `//demo.dotcms.com` + // for the batch -- neither naming where the files actually went, and never equal. The + // files arrived; the listing they arrived in sat stale. + const drive = new ContentDrivePage(adminPage); + const title = `sys-upload-${testSuffix}.png`; + + await drive.goTo(); + await drive.selectSystemHost(); + await drive.chooseFilesForUpload([title]); + + // No reload of our own: the refresh arriving by itself is the whole assertion. + await drive.expectListContainsTitle(title); + }); + + test('reports a run in flight and stops when it settles @critical', async ({ + adminPage, + apiHelpers, + testSuffix + }) => { + // The toast replaced the toolbar indicator, and it is raised `sticky` so it cannot time + // itself out. That makes ending it the store's job rather than PrimeNG's, which is the + // half worth pinning: a run that never clears leaves the portlet claiming work is in + // flight forever. + const site = await apiHelpers.getDefaultSite(); + const folderName = `cd-toast-${testSuffix}`; + await apiHelpers.createFolders(site.hostname, [`/${folderName}`]); + + const drive = new ContentDrivePage(adminPage); + + try { + await drive.goTo(); + await drive.openFolder(folderName); + await drive.chooseFilesForUpload([`toast-${testSuffix}.png`]); + + // Its own words, not the workflow sentence: an upload puts files INTO a place rather + // than applying an action TO content, which is what "Applying Upload to ..." claimed. + await drive.expectStatusToastContaining('Uploading'); + await drive.expectStatusToastGone(); + } finally { + await apiHelpers.deleteFolders(site.hostname, [`/${folderName}`]); + } + }); }); diff --git a/core-web/apps/dotcms-ui-e2e/src/tests/content-drive/helpers/content-drive-tree.ts b/core-web/apps/dotcms-ui-e2e/src/tests/content-drive/helpers/content-drive-tree.ts index b20f7b0a0550..dd67de68a809 100644 --- a/core-web/apps/dotcms-ui-e2e/src/tests/content-drive/helpers/content-drive-tree.ts +++ b/core-web/apps/dotcms-ui-e2e/src/tests/content-drive/helpers/content-drive-tree.ts @@ -87,6 +87,27 @@ export class ContentDriveTree { await expect(node).toBeVisible({ timeout: 10000 }); } + /** + * How many rows inside the hierarchy read as selected. + * + * The two standalone entries reuse `p-tree-node-content` and `p-tree-node-selected` so they are + * styled like the tree they sit around, which means a document-wide count of the class answers + * the wrong question. Scoped to the tree, this is the assertion that the sidebar is not + * claiming the user is in two places at once. + */ + async expectNothingSelected() { + // `hierarchy-scroll`, not `sidebar`: this class's root is the whole panel, and the two + // standalone entries deliberately reuse `p-tree-node-content` / `p-tree-node-selected` so + // they match the rows they sit around. Counting from the panel would therefore count the + // selected System Host button as a selected tree row, and the assertion would pass or fail + // for the wrong reason. That wrapper holds the hierarchy and nothing else. + await expect( + this.root + .getByTestId('hierarchy-scroll') + .locator('.p-tree-node-content.p-tree-node-selected') + ).toHaveCount(0, { timeout: 10000 }); + } + /** * The state-aware folder icon the shared tree renders on a folder row (#37362). * diff --git a/core-web/libs/portlets/dot-content-drive/portlet/src/lib/components/dot-content-drive-toolbar/dot-content-drive-toolbar.component.spec.ts b/core-web/libs/portlets/dot-content-drive/portlet/src/lib/components/dot-content-drive-toolbar/dot-content-drive-toolbar.component.spec.ts index f17ea6bfff4a..10901b15c93e 100644 --- a/core-web/libs/portlets/dot-content-drive/portlet/src/lib/components/dot-content-drive-toolbar/dot-content-drive-toolbar.component.spec.ts +++ b/core-web/libs/portlets/dot-content-drive/portlet/src/lib/components/dot-content-drive-toolbar/dot-content-drive-toolbar.component.spec.ts @@ -710,7 +710,12 @@ describe('DotContentDriveToolbarComponent', () => { // when the run settles. Refusing to open it is the honest version of that state. selectedItemsSignal.set([MOCK_ITEMS[0]]); activeRunCountSignal.set(1); - actionExecutionSignal.set({ actionName: 'Publish', total: 3 }); + actionExecutionSignal.set({ + actionName: 'Upload', + total: 3, + labelKey: 'content-drive.upload.indicator', + targetLabel: 'demo.dotcms.com' + }); await settleToolbarAnimation(spectator); const button = spectator @@ -754,7 +759,12 @@ describe('DotContentDriveToolbarComponent', () => { it('should explain why it is disabled while an action is running', async () => { selectedItemsSignal.set([MOCK_ITEMS[0]]); activeRunCountSignal.set(1); - actionExecutionSignal.set({ actionName: 'Publish', total: 3 }); + actionExecutionSignal.set({ + actionName: 'Upload', + total: 3, + labelKey: 'content-drive.upload.indicator', + targetLabel: 'demo.dotcms.com' + }); await settleToolbarAnimation(spectator); expect(spectator.component.$actionCenterTooltip()).toBe( @@ -772,7 +782,12 @@ describe('DotContentDriveToolbarComponent', () => { it('should not open the dialog while an action is running', async () => { selectedItemsSignal.set([MOCK_ITEMS[0]]); activeRunCountSignal.set(1); - actionExecutionSignal.set({ actionName: 'Publish', total: 3 }); + actionExecutionSignal.set({ + actionName: 'Upload', + total: 3, + labelKey: 'content-drive.upload.indicator', + targetLabel: 'demo.dotcms.com' + }); await settleToolbarAnimation(spectator); // Guards the handler too: a disabled attribute alone would leave the store reachable. @@ -784,7 +799,12 @@ describe('DotContentDriveToolbarComponent', () => { it('should become available again once the run settles', async () => { selectedItemsSignal.set([MOCK_ITEMS[0]]); activeRunCountSignal.set(1); - actionExecutionSignal.set({ actionName: 'Publish', total: 3 }); + actionExecutionSignal.set({ + actionName: 'Upload', + total: 3, + labelKey: 'content-drive.upload.indicator', + targetLabel: 'demo.dotcms.com' + }); await settleToolbarAnimation(spectator); // A settled run leaves neither a name nor a count. Clearing only the name described a @@ -817,62 +837,75 @@ describe('DotContentDriveToolbarComponent', () => { expect(statusMessages()).toHaveLength(0); }); - it('should report the action and the number of items once a run starts', () => { - // The toolbar is the only place still reporting the run after the Action Center dialog - // is closed, which is the whole reason this is reported outside it. + it('should escape markup carried by the destination an upload names', () => { + // The surviving interpolation: a run's own copy carries ``, so the label is bound + // with `[innerHTML]`, and the destination is a hostname or a folder path an author can + // influence. Escaping is what keeps the message the only source of markup in itself. + const messageService = spectator.inject(DotMessageService); + + vi.spyOn(messageService, 'get').mockImplementation((key: string, ...args: string[]) => + key === 'content-drive.upload.indicator' + ? `Uploading ${args[1]} file(s) to ${args[0]}…` + : key + ); + activeRunCountSignal.set(1); - actionExecutionSignal.set({ actionName: 'Publish', total: 3 }); + actionExecutionSignal.set({ + actionName: 'Upload', + total: 2, + labelKey: 'content-drive.upload.indicator', + targetLabel: '' + }); spectator.detectChanges(); spectator.flushEffects(); - expect(statusMessages()).toHaveLength(1); - expect(spectator.component.$actionExecutionLabel()).toBe( - 'content-drive.action-center.applying' - ); + const label = spectator.component.$actionExecutionLabel(); + + expect(label).not.toContain(' { + it('should stay silent for a run that brought no copy of its own', () => { + // Nothing reaches this surface unless it is unmarked, and every unmarked run today is + // an upload, which names itself. A run arriving here without a `labelKey` is therefore + // a run nobody has written words for, and inventing "Applying X to N item(s)" for it + // was how an upload came to read "Applying Upload to demo.dotcms.com" -- a sentence for + // an action performed ON content, not for files going INTO a place. activeRunCountSignal.set(1); actionExecutionSignal.set({ actionName: 'Publish', total: 3 }); spectator.detectChanges(); spectator.flushEffects(); - const [message] = statusMessages(); - - expect(message).toEqual( - expect.objectContaining({ sticky: true, icon: 'pi pi-spin pi-spinner' }) - ); + expect(spectator.component.$actionExecutionLabel()).toBe(''); + expect(statusMessages()).toHaveLength(0); }); - it('should escape markup carried by the action name', () => { - // Workflow action names come from the backend verbatim, and the label is rendered as - // HTML so the message's own `` shows. A name carrying markup must not become live - // DOM — escaping happens as the label is composed, which is where this now asserts. - const messageService = spectator.inject(DotMessageService); - - vi.spyOn(messageService, 'get').mockImplementation((key: string, ...args: string[]) => - key === 'content-drive.action-center.applying' - ? `Applying ${args[0]} to ${args[1]} item(s)…` - : key - ); - + it('should say the run is still going rather than reporting an outcome', () => { activeRunCountSignal.set(1); actionExecutionSignal.set({ - actionName: '', - total: 3 + actionName: 'Upload', + total: 3, + labelKey: 'content-drive.upload.indicator', + targetLabel: 'demo.dotcms.com' }); spectator.detectChanges(); spectator.flushEffects(); - const label = spectator.component.$actionExecutionLabel(); + const [message] = statusMessages(); - expect(label).not.toContain(' { activeRunCountSignal.set(1); - actionExecutionSignal.set({ actionName: 'Publish', total: 3 }); + actionExecutionSignal.set({ + actionName: 'Upload', + total: 3, + labelKey: 'content-drive.upload.indicator', + targetLabel: 'demo.dotcms.com' + }); spectator.detectChanges(); spectator.flushEffects(); @@ -888,7 +921,12 @@ describe('DotContentDriveToolbarComponent', () => { // The indicator this replaced changed its text in place. Clearing and raising again on // every store touch would dismiss and re-animate the toast for no new information. activeRunCountSignal.set(1); - actionExecutionSignal.set({ actionName: 'Publish', total: 3 }); + actionExecutionSignal.set({ + actionName: 'Upload', + total: 3, + labelKey: 'content-drive.upload.indicator', + targetLabel: 'demo.dotcms.com' + }); spectator.detectChanges(); spectator.flushEffects(); @@ -904,7 +942,12 @@ describe('DotContentDriveToolbarComponent', () => { it('should report the new wording when a second run joins', () => { activeRunCountSignal.set(1); - actionExecutionSignal.set({ actionName: 'Publish', total: 3 }); + actionExecutionSignal.set({ + actionName: 'Upload', + total: 3, + labelKey: 'content-drive.upload.indicator', + targetLabel: 'demo.dotcms.com' + }); spectator.detectChanges(); spectator.flushEffects(); @@ -930,60 +973,6 @@ describe('DotContentDriveToolbarComponent', () => { 'content-drive.action-center.applying-many' ); }); - - it('should name the item, not a count, when the run is over a single thing', () => { - // "Applying Publish to 1 item(s)" is worse than useless on a context-menu action: the - // author knows it is one item, what they cannot see is *which*. - activeRunCountSignal.set(1); - actionExecutionSignal.set({ - actionName: 'Publish', - total: 1, - targetLabel: 'My Page' - }); - spectator.detectChanges(); - spectator.flushEffects(); - - expect(spectator.component.$actionExecutionLabel()).toBe( - 'content-drive.action-center.applying-item' - ); - }); - - it('should keep the count form when several items are in play', () => { - activeRunCountSignal.set(1); - actionExecutionSignal.set({ actionName: 'Publish', total: 12 }); - spectator.detectChanges(); - spectator.flushEffects(); - - expect(spectator.component.$actionExecutionLabel()).toBe( - 'content-drive.action-center.applying' - ); - }); - - it('should escape markup carried by the item name', () => { - // `actionName` comes from the backend; a title is typed by an author, so this is the - // likelier of the two to carry markup. - const messageService = spectator.inject(DotMessageService); - - vi.spyOn(messageService, 'get').mockImplementation((key: string, ...args: string[]) => - key === 'content-drive.action-center.applying-item' - ? `Applying ${args[0]} to ${args[1]}` - : key - ); - - activeRunCountSignal.set(1); - actionExecutionSignal.set({ - actionName: 'Publish', - total: 1, - targetLabel: '' - }); - spectator.detectChanges(); - spectator.flushEffects(); - - const label = spectator.component.$actionExecutionLabel(); - - expect(label).not.toContain(' { diff --git a/core-web/libs/portlets/dot-content-drive/portlet/src/lib/components/dot-content-drive-toolbar/dot-content-drive-toolbar.component.ts b/core-web/libs/portlets/dot-content-drive/portlet/src/lib/components/dot-content-drive-toolbar/dot-content-drive-toolbar.component.ts index 7e20a1f21446..f4e4731c97d5 100644 --- a/core-web/libs/portlets/dot-content-drive/portlet/src/lib/components/dot-content-drive-toolbar/dot-content-drive-toolbar.component.ts +++ b/core-web/libs/portlets/dot-content-drive/portlet/src/lib/components/dot-content-drive-toolbar/dot-content-drive-toolbar.component.ts @@ -63,9 +63,10 @@ const HTML_ESCAPES: Record = { /** * Escapes a value that will be interpolated into a message rendered as HTML. * - * Needed only because `content-drive.action-center.applying` carries its own ``, which forces the - * label to be bound with `[innerHTML]` rather than interpolated. Everything substituted into such a - * message has to be escaped, or the message stops being the only source of markup in it. + * Needed because a run's own copy (`content-drive.upload.indicator` and friends) carries its own + * ``, which forces the label to be bound with `[innerHTML]` rather than interpolated. Everything + * substituted into such a message has to be escaped, or the message stops being the only source of + * markup in it. */ const escapeHtml = (value: string): string => value.replace(/[&<>"']/g, (char) => HTML_ESCAPES[char]); @@ -376,6 +377,14 @@ export class DotContentDriveToolbarComponent { return; } + // A run with nothing to say raises nothing rather than an empty pill. + if (!label) { + this.#shownRunLabel = undefined; + this.#messageService.clear(STATUS_TOAST_KEY); + + return; + } + // Only when the wording actually changes. PrimeNG has no update, so re-reporting means // clearing and raising again — and the old inline indicator simply changed its text, // so re-animating on every store touch would be a behaviour this replaced, not kept. @@ -421,33 +430,29 @@ export class DotContentDriveToolbarComponent { : ''; } - // Both halves are escaped, and the item name matters more: `actionName` is a - // `WorkflowAction.name` from the backend, but a target label is content an author typed. - const actionName = escapeHtml(execution.actionName); - - // A run carrying its own copy uses it: the "Applying X to Y" form describes an operation - // being performed on something, which is not what every run is. - if (execution.labelKey) { - return this.#dotMessageService.get( - execution.labelKey, - escapeHtml(execution.targetLabel ?? ''), - String(execution.total) - ); - } - - // "Applying Publish to 1 item(s)" tells an author nothing they did not already know. When - // the run is over one nameable thing, name it. - return execution.targetLabel + // A run says whatever it brought, and nothing otherwise. + // + // There used to be an "Applying X to Y" fallback here for runs with no copy of their own. + // Nothing could reach it: only an *unmarked* run arrives here (`toolbarRun` filters to + // `targets.length === 0`, because a run whose rows are marked in the grid is already + // telling the author where it is), and every unmarked run is an upload, which names + // itself. Its one real effect was on uploads before they had their own words, where it + // produced "Applying Upload to demo.dotcms.com" -- a sentence for an action performed ON + // content rather than for files going INTO a place. + // + // So a run arriving with no `labelKey` is one nobody has written words for, and inventing + // some is what caused that. Silence is the honest answer, and the effect above raises + // nothing for an empty label. + // + // `targetLabel` is escaped because the label is bound with `[innerHTML]` -- the message + // carries its own `` -- and a target label is content an author typed. + return execution.labelKey ? this.#dotMessageService.get( - 'content-drive.action-center.applying-item', - actionName, - escapeHtml(execution.targetLabel) - ) - : this.#dotMessageService.get( - 'content-drive.action-center.applying', - actionName, + execution.labelKey, + escapeHtml(execution.targetLabel ?? ''), String(execution.total) - ); + ) + : ''; }); /** diff --git a/dotCMS/src/main/webapp/WEB-INF/messages/Language.properties b/dotCMS/src/main/webapp/WEB-INF/messages/Language.properties index 7047fab4a3cc..19edc25dced6 100644 --- a/dotCMS/src/main/webapp/WEB-INF/messages/Language.properties +++ b/dotCMS/src/main/webapp/WEB-INF/messages/Language.properties @@ -7468,8 +7468,6 @@ content-drive.action-center.section.pushPublish=Push publish content-drive.action-center.section.bundle=Bundle content-drive.action-center.push-publish.no-environment=Choose at least one environment to continue. content-drive.action-center.approximate-count=This action has a condition that is evaluated per item, so it may apply to fewer items than shown. -content-drive.action-center.applying=Applying {0} to {1} item(s)… -content-drive.action-center.applying-item=Applying {0} to {1}… content-drive.action-center.applying-many={0} operations running… content-drive.list-view.row-busy=An action is running on this item… content-drive.action-center.busy=Wait for the running action to finish before starting another. From c9bf503364dacee436d0e1acd26fccc0f08c1cf1 Mon Sep 17 00:00:00 2001 From: Jalinson Diaz Date: Wed, 16 Sep 2026 15:32:55 -0300 Subject: [PATCH 35/44] feat(content-drive): ease the folder dialog between tabs instead of snapping (#37426) The three tabs hold different amounts, so moving between them resized the dialog in a single frame and read as a bounce. The panels now sit in a viewport whose height the component writes from their own measurement. That number has to come from script rather than a stylesheet: CSS cannot ease to or from the "auto" keyword, so there is nothing for a transition to interpolate between while the height is content-derived. Driven by a ResizeObserver on the panels rather than by the tab selection, because two different things change that height and both deserve the same treatment: switching tabs, and a tab growing in place when a file-extension chip is added or a validation message appears. Watching the result covers both and cannot fall out of step with how many tabs there happen to be. There is no feedback loop, because the observed element sizes itself to its content while what is written back is its parent's height, which constrains nothing. Clipped only while it moves. A panel shrinking into a shorter box would spill over the footer on the way down, so `overflow` goes on with the height change and comes off at `transitionend` -- nothing is clipped at rest. Safe here because the two overlays in the Upload Behavior tab already escape via `appendTo="body"` (#35578), so the box cannot re-break what that fix addressed. `prefers-reduced-motion` gets the old instant resize rather than a slower version of the thing it asked to turn off. No unit test: this is a transition and a measured height, and asserting on either pins the implementation rather than the behaviour. It wants eyes in a browser, and the duration is one value in the component's styles. One trap worth recording. The inline stylesheet is a template literal, and the CSS comments first written here quoted `auto` in backticks. Escaping them is valid JavaScript, but Angular's JIT lifts the CSS into its own virtual module where the escape does not survive, so the extracted stylesheet was parsed as script and died on the identifier. Two spec files then failed to load and 294 tests silently did not run while every test that did run passed. Co-Authored-By: Claude Opus 5 (1M context) --- ...content-drive-dialog-folder.component.html | 287 +++++++++--------- ...t-content-drive-dialog-folder.component.ts | 90 +++++- 2 files changed, 239 insertions(+), 138 deletions(-) diff --git a/core-web/libs/portlets/dot-content-drive/portlet/src/lib/components/dialogs/dot-content-drive-dialog-folder/dot-content-drive-dialog-folder.component.html b/core-web/libs/portlets/dot-content-drive/portlet/src/lib/components/dialogs/dot-content-drive-dialog-folder/dot-content-drive-dialog-folder.component.html index b38b02d26bf7..e7d9a186a676 100644 --- a/core-web/libs/portlets/dot-content-drive/portlet/src/lib/components/dialogs/dot-content-drive-dialog-folder/dot-content-drive-dialog-folder.component.html +++ b/core-web/libs/portlets/dot-content-drive/portlet/src/lib/components/dialogs/dot-content-drive-dialog-folder/dot-content-drive-dialog-folder.component.html @@ -12,151 +12,164 @@ {{ 'content-drive.dialog.folder.advanced.header' | dm }} - - -
- - -
-
- - -
+ +
+ + +
+ + +
+
+ + +
-
- {{ 'content-drive.dialog.folder.field.path' | dm }} -

- {{ $finalPath() }} -

-
-
- -
- -
- @for (option of uploadBehaviorOptions; track option.labelKey) { -
-
- - - - - {{ - 'content-drive.dialog.folder.field.allowed-file-extensions.help' - | dm - }} - -
+ + + {{ + 'content-drive.dialog.folder.field.allowed-file-extensions.help' + | dm + }} + +
-
- - - -
-
- -
- - -
+ +
+
+ +
+ + +
-
- - -
-
-
+
+ + +
+ + + } @else { diff --git a/core-web/libs/portlets/dot-content-drive/portlet/src/lib/components/dialogs/dot-content-drive-dialog-folder/dot-content-drive-dialog-folder.component.ts b/core-web/libs/portlets/dot-content-drive/portlet/src/lib/components/dialogs/dot-content-drive-dialog-folder/dot-content-drive-dialog-folder.component.ts index 39028cb35a7d..b7ac981a2c42 100644 --- a/core-web/libs/portlets/dot-content-drive/portlet/src/lib/components/dialogs/dot-content-drive-dialog-folder/dot-content-drive-dialog-folder.component.ts +++ b/core-web/libs/portlets/dot-content-drive/portlet/src/lib/components/dialogs/dot-content-drive-dialog-folder/dot-content-drive-dialog-folder.component.ts @@ -1,8 +1,12 @@ import { + afterNextRender, Component, computed, + DestroyRef, effect, + ElementRef, inject, + Injector, input, signal, viewChild, @@ -69,7 +73,33 @@ interface FolderForm { ], templateUrl: './dot-content-drive-dialog-folder.component.html', changeDetection: ChangeDetectionStrategy.Eager, - host: { class: 'block' } + host: { class: 'block' }, + styles: [ + ` + /* The three tabs hold different amounts, so switching between them resized the dialog + in one frame and it read as a snap. The height is written by the component from the + panels' own measurement, because CSS cannot ease to or from the "auto" keyword. + + Clipped only while it moves: "overflow" is cleared as soon as the transition ends, + so anything that has to escape the dialog at rest still can. */ + .tabpanels-viewport { + transition: height 200ms ease; + will-change: height; + } + + .tabpanels-viewport--moving { + overflow: hidden; + } + + /* Someone who has asked for less motion gets the old instant resize rather than a + slower version of the thing they turned off. */ + @media (prefers-reduced-motion: reduce) { + .tabpanels-viewport { + transition: none; + } + } + ` + ] }) export class DotContentDriveDialogFolderComponent { #fb = inject(FormBuilder); @@ -108,6 +138,64 @@ export class DotContentDriveDialogFolderComponent { /** Allowed-file-extensions field; chips are added through its own model. See {@link #addExtension}. */ readonly $extensionsAutoComplete = viewChild('extensionsAutoComplete'); + /** The box whose height is eased so the dialog grows into a taller tab instead of snapping. */ + readonly $panelsViewport = viewChild>('panelsViewport'); + + readonly #injector = inject(Injector); + readonly #destroyRef = inject(DestroyRef); + + constructor() { + // After the first render, because there is nothing to measure until the form exists: the + // whole tab set sits behind `@if ($formReady())`. + afterNextRender(() => this.#easePanelHeight(), { injector: this.#injector }); + } + + /** + * Keeps the viewport's height in step with whichever panel is showing. + * + * Driven by a `ResizeObserver` on the panels rather than by the tab selection, because the two + * causes of a height change deserve the same treatment: switching tabs, and a tab growing in + * place when a file-extension chip is added or a validation message appears. Watching the + * result covers both, and cannot fall out of step with how many tabs there happen to be. + * + * No feedback loop: the observed element sizes itself to its content, and what is written back + * is the height of its *parent*, which constrains nothing. + */ + #easePanelHeight(): void { + const viewport = this.$panelsViewport()?.nativeElement; + const panels = viewport?.firstElementChild; + + if (!viewport || !panels) { + return; + } + + // The first measurement is the height it opens at, so it must not animate from zero. + viewport.style.height = `${panels.getBoundingClientRect().height}px`; + + const clearClip = () => viewport.classList.remove('tabpanels-viewport--moving'); + viewport.addEventListener('transitionend', clearClip); + + const observer = new ResizeObserver(([entry]) => { + const height = entry.contentRect.height; + + if (Math.round(height) === Math.round(parseFloat(viewport.style.height))) { + return; + } + + // Clipped for the duration only: a panel that is taller than the box it is shrinking + // into would otherwise spill over the dialog's footer on the way down. + viewport.classList.add('tabpanels-viewport--moving'); + viewport.style.height = `${height}px`; + }); + + observer.observe(panels); + + this.#destroyRef.onDestroy(() => { + observer.disconnect(); + viewport.removeEventListener('transitionend', clearClip); + }); + } + folderForm: FormGroup = this.#fb.group({ title: this.#fb.control('', { validators: [Validators.required], nonNullable: true }), sortOrder: this.#fb.control(1), From d46e02439e0dd53f73ef5eb632116a1a958b0c9a Mon Sep 17 00:00:00 2001 From: Jalinson Diaz Date: Wed, 16 Sep 2026 15:38:42 -0300 Subject: [PATCH 36/44] Revert "feat(content-drive): ease the folder dialog between tabs instead of snapping (#37426)" This reverts commit c9bf503364dacee436d0e1acd26fccc0f08c1cf1. --- ...content-drive-dialog-folder.component.html | 287 +++++++++--------- ...t-content-drive-dialog-folder.component.ts | 90 +----- 2 files changed, 138 insertions(+), 239 deletions(-) diff --git a/core-web/libs/portlets/dot-content-drive/portlet/src/lib/components/dialogs/dot-content-drive-dialog-folder/dot-content-drive-dialog-folder.component.html b/core-web/libs/portlets/dot-content-drive/portlet/src/lib/components/dialogs/dot-content-drive-dialog-folder/dot-content-drive-dialog-folder.component.html index e7d9a186a676..b38b02d26bf7 100644 --- a/core-web/libs/portlets/dot-content-drive/portlet/src/lib/components/dialogs/dot-content-drive-dialog-folder/dot-content-drive-dialog-folder.component.html +++ b/core-web/libs/portlets/dot-content-drive/portlet/src/lib/components/dialogs/dot-content-drive-dialog-folder/dot-content-drive-dialog-folder.component.html @@ -12,164 +12,151 @@ {{ 'content-drive.dialog.folder.advanced.header' | dm }} - -
- - -
- - -
-
- - -
+ + +
+ + +
+
+ + +
-
- {{ 'content-drive.dialog.folder.field.path' | dm }} -

- {{ $finalPath() }} -

-
-
- -
- -
- @for (option of uploadBehaviorOptions; track option.labelKey) { -
-
- - - - - {{ - 'content-drive.dialog.folder.field.allowed-file-extensions.help' - | dm - }} - -
+ + + {{ + 'content-drive.dialog.folder.field.allowed-file-extensions.help' + | dm + }} + +
-
- - - -
-
- -
- - -
+ +
+ + +
+ + +
-
- - -
-
- - +
+ + +
+ + } @else { diff --git a/core-web/libs/portlets/dot-content-drive/portlet/src/lib/components/dialogs/dot-content-drive-dialog-folder/dot-content-drive-dialog-folder.component.ts b/core-web/libs/portlets/dot-content-drive/portlet/src/lib/components/dialogs/dot-content-drive-dialog-folder/dot-content-drive-dialog-folder.component.ts index b7ac981a2c42..39028cb35a7d 100644 --- a/core-web/libs/portlets/dot-content-drive/portlet/src/lib/components/dialogs/dot-content-drive-dialog-folder/dot-content-drive-dialog-folder.component.ts +++ b/core-web/libs/portlets/dot-content-drive/portlet/src/lib/components/dialogs/dot-content-drive-dialog-folder/dot-content-drive-dialog-folder.component.ts @@ -1,12 +1,8 @@ import { - afterNextRender, Component, computed, - DestroyRef, effect, - ElementRef, inject, - Injector, input, signal, viewChild, @@ -73,33 +69,7 @@ interface FolderForm { ], templateUrl: './dot-content-drive-dialog-folder.component.html', changeDetection: ChangeDetectionStrategy.Eager, - host: { class: 'block' }, - styles: [ - ` - /* The three tabs hold different amounts, so switching between them resized the dialog - in one frame and it read as a snap. The height is written by the component from the - panels' own measurement, because CSS cannot ease to or from the "auto" keyword. - - Clipped only while it moves: "overflow" is cleared as soon as the transition ends, - so anything that has to escape the dialog at rest still can. */ - .tabpanels-viewport { - transition: height 200ms ease; - will-change: height; - } - - .tabpanels-viewport--moving { - overflow: hidden; - } - - /* Someone who has asked for less motion gets the old instant resize rather than a - slower version of the thing they turned off. */ - @media (prefers-reduced-motion: reduce) { - .tabpanels-viewport { - transition: none; - } - } - ` - ] + host: { class: 'block' } }) export class DotContentDriveDialogFolderComponent { #fb = inject(FormBuilder); @@ -138,64 +108,6 @@ export class DotContentDriveDialogFolderComponent { /** Allowed-file-extensions field; chips are added through its own model. See {@link #addExtension}. */ readonly $extensionsAutoComplete = viewChild('extensionsAutoComplete'); - /** The box whose height is eased so the dialog grows into a taller tab instead of snapping. */ - readonly $panelsViewport = viewChild>('panelsViewport'); - - readonly #injector = inject(Injector); - readonly #destroyRef = inject(DestroyRef); - - constructor() { - // After the first render, because there is nothing to measure until the form exists: the - // whole tab set sits behind `@if ($formReady())`. - afterNextRender(() => this.#easePanelHeight(), { injector: this.#injector }); - } - - /** - * Keeps the viewport's height in step with whichever panel is showing. - * - * Driven by a `ResizeObserver` on the panels rather than by the tab selection, because the two - * causes of a height change deserve the same treatment: switching tabs, and a tab growing in - * place when a file-extension chip is added or a validation message appears. Watching the - * result covers both, and cannot fall out of step with how many tabs there happen to be. - * - * No feedback loop: the observed element sizes itself to its content, and what is written back - * is the height of its *parent*, which constrains nothing. - */ - #easePanelHeight(): void { - const viewport = this.$panelsViewport()?.nativeElement; - const panels = viewport?.firstElementChild; - - if (!viewport || !panels) { - return; - } - - // The first measurement is the height it opens at, so it must not animate from zero. - viewport.style.height = `${panels.getBoundingClientRect().height}px`; - - const clearClip = () => viewport.classList.remove('tabpanels-viewport--moving'); - viewport.addEventListener('transitionend', clearClip); - - const observer = new ResizeObserver(([entry]) => { - const height = entry.contentRect.height; - - if (Math.round(height) === Math.round(parseFloat(viewport.style.height))) { - return; - } - - // Clipped for the duration only: a panel that is taller than the box it is shrinking - // into would otherwise spill over the dialog's footer on the way down. - viewport.classList.add('tabpanels-viewport--moving'); - viewport.style.height = `${height}px`; - }); - - observer.observe(panels); - - this.#destroyRef.onDestroy(() => { - observer.disconnect(); - viewport.removeEventListener('transitionend', clearClip); - }); - } - folderForm: FormGroup = this.#fb.group({ title: this.#fb.control('', { validators: [Validators.required], nonNullable: true }), sortOrder: this.#fb.control(1), From 4a8f41da26eb781da53c421405da3a26354bbd8d Mon Sep 17 00:00:00 2001 From: Jalinson Diaz Date: Wed, 16 Sep 2026 15:52:31 -0300 Subject: [PATCH 37/44] style(ui): line the sidebar chevron up, and drop the toast's left stripe (#37426) Two unrelated bits of polish, both verified in the browser rather than reasoned about in the markup. The tree's chevron sat out of line with the icons on the rows above and below it. What has to match is the middle of each mark, not the left of its box: the chevron is a 10.5px glyph centred in a 24.5px button, while those rows carry a 16px icon, so lining up the boxes still leaves the chevron looking about 4px to the right of everything else. Measured, the icons centre at 85 and the chevron centred at 89.25. The tree's left inset now leaves the three in one column. It goes on the sidebar's own `.p-tree` rule rather than in the shared tree, which knows nothing about the rows it happens to sit between -- the offset is a fact about this layout, not about trees. Lara draws a 6px severity-coloured stripe down the left edge of every toast (`borderWidth: '0 0 0 6px'`). The preset now sets it to zero, so a toast is a plain rectangle app-wide. Severity is already carried by the background, the icon and the text colour; the stripe was a second and louder way of saying the same thing. Done in the preset because it is a decision about every toast in the product, not about the one this branch added. Co-Authored-By: Claude Opus 5 (1M context) --- .../dot-content-drive-sidebar.component.ts | 11 ++++++++++- core-web/libs/ui/src/lib/theme/theme.config.ts | 10 ++++++++++ 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/core-web/libs/portlets/dot-content-drive/portlet/src/lib/components/dot-content-drive-sidebar/dot-content-drive-sidebar.component.ts b/core-web/libs/portlets/dot-content-drive/portlet/src/lib/components/dot-content-drive-sidebar/dot-content-drive-sidebar.component.ts index 1098ecfe9c18..a6d7b427eda2 100644 --- a/core-web/libs/portlets/dot-content-drive/portlet/src/lib/components/dot-content-drive-sidebar/dot-content-drive-sidebar.component.ts +++ b/core-web/libs/portlets/dot-content-drive/portlet/src/lib/components/dot-content-drive-sidebar/dot-content-drive-sidebar.component.ts @@ -53,7 +53,16 @@ import { appendLoadMoreNodes, mergeFolderNodePage } from '../../utils/functions' that first row on the toolbar's search box and tree toggler beside it, so it has to travel with the row rather than stay on the tree. */ :host ::ng-deep .p-tree { - padding: 0 0.75rem 0.75rem; + /* Almost no left inset, so the tree's chevron sits in the same column as the icons on + the rows above and below it. + + What has to match is the middle of each mark, not the left of its box. The chevron is + a 10.5px glyph centred in a 24.5px button, while those rows carry a 16px icon, so + lining the boxes up leaves the chevron looking 4px to the right of everything else. + Working back from the icon centre through the row's own 0.625rem leaves this much for + the tree, and it belongs to the sidebar layout rather than to the shared tree, which + knows nothing about the rows it happens to sit between. */ + padding: 0 0.75rem 0.75rem 1px; } ` }) diff --git a/core-web/libs/ui/src/lib/theme/theme.config.ts b/core-web/libs/ui/src/lib/theme/theme.config.ts index f481aa375178..080114dc29a8 100644 --- a/core-web/libs/ui/src/lib/theme/theme.config.ts +++ b/core-web/libs/ui/src/lib/theme/theme.config.ts @@ -226,6 +226,16 @@ export const CustomLaraPreset = definePreset(Lara, { } } }, + toast: { + root: { + // Lara draws a 6px severity-coloured stripe down the left edge + // (its own default is '0 0 0 6px'). Every toast in the app is a plain + // rectangle instead: severity is already carried by the background, the + // icon and the text colour, and the stripe only adds a second, louder + // way to say the same thing. + borderWidth: '0' + } + }, toolbar: { root: { borderRadius: '0', From 0d8ae66de7e2812d5223c547133eeaee77098755 Mon Sep 17 00:00:00 2001 From: Jalinson Diaz Date: Wed, 16 Sep 2026 16:05:32 -0300 Subject: [PATCH 38/44] style(content-drive): shorten the upload status, and stop it being closable (#37426) The in-flight line was 39 characters, and 57 once a batch went to the background -- nearly three times the length of the other thing this surface says ("2 operations running"). It now reads "Uploading 3 files" and "Uploading 3 files in the background": 18 and 36. What went is the destination. Developer's call, made knowing the trade: the destination was added because the site switcher still names a site while System Host is browsed, so an upload there no longer says where it landed. The drive does show where the author is, and the sentence had grown long enough that the part they could read at a glance was getting lost behind the part they could not. Dropping the "file(s)" hedge means the count has to pick the noun, and only the caller knows the count, so the shell chooses the key. Four messages rather than two, which is what spelling a word out costs. The status is no longer closable. A status is not a message the reader has to deal with: it reports work already under way and clears when that work settles, so a close button asks them to tidy up after something they did not start and cannot affect. That needed fixing in two places, because they fail differently. PrimeNG reads `closable` off the MESSAGE (`message?.closable !== false`) and has no input on the component, so `[closable]="false"` on `p-toast` is a no-op -- written, and caught in the browser rather than by a test. The toolbar now raises the message with `closable: false`, which keeps the button out of the DOM entirely, and the outlet hides `.p-toast-close-button` in its own stylesheet so it stays safe when a future caller forgets. `display: none` also takes it out of the tab order, rather than leaving a control that can be reached but not seen. The tests split along the same line: the toolbar spec pins that the message carries `closable: false`, and the outlet spec claims only that it renders no close control of its own. Assertions on the label key are matched by prefix where the test is about behaviour -- that one file is still announced as backgrounded -- so they no longer fail over which noun the sentence uses. Co-Authored-By: Claude Opus 5 (1M context) --- ...ot-content-drive-toolbar.component.spec.ts | 10 +++- .../dot-content-drive-toolbar.component.ts | 6 ++- .../dot-content-drive-shell.component.spec.ts | 30 +++++++++-- .../dot-content-drive-shell.component.ts | 27 +++++++++- .../dot-status-toast.component.html | 13 ++--- .../dot-status-toast.component.scss | 50 ++++++------------- .../dot-status-toast.component.spec.ts | 17 +++---- .../dot-status-toast.component.ts | 25 +++------- .../WEB-INF/messages/Language.properties | 6 ++- 9 files changed, 99 insertions(+), 85 deletions(-) diff --git a/core-web/libs/portlets/dot-content-drive/portlet/src/lib/components/dot-content-drive-toolbar/dot-content-drive-toolbar.component.spec.ts b/core-web/libs/portlets/dot-content-drive/portlet/src/lib/components/dot-content-drive-toolbar/dot-content-drive-toolbar.component.spec.ts index 10901b15c93e..ada8528e84a1 100644 --- a/core-web/libs/portlets/dot-content-drive/portlet/src/lib/components/dot-content-drive-toolbar/dot-content-drive-toolbar.component.spec.ts +++ b/core-web/libs/portlets/dot-content-drive/portlet/src/lib/components/dot-content-drive-toolbar/dot-content-drive-toolbar.component.spec.ts @@ -893,8 +893,16 @@ describe('DotContentDriveToolbarComponent', () => { const [message] = statusMessages(); + // `closable: false` belongs on the message because that is where PrimeNG looks for + // it — its template reads `message?.closable !== false` and the outlet has no input + // that can set it. Sticky for the same reason the run owns the toast's life: it ends + // when the work ends, not on a timer that could blank it mid-upload. expect(message).toEqual( - expect.objectContaining({ sticky: true, icon: 'pi pi-spin pi-spinner' }) + expect.objectContaining({ + sticky: true, + closable: false, + icon: 'pi pi-spin pi-spinner' + }) ); }); diff --git a/core-web/libs/portlets/dot-content-drive/portlet/src/lib/components/dot-content-drive-toolbar/dot-content-drive-toolbar.component.ts b/core-web/libs/portlets/dot-content-drive/portlet/src/lib/components/dot-content-drive-toolbar/dot-content-drive-toolbar.component.ts index f4e4731c97d5..29774eac57ff 100644 --- a/core-web/libs/portlets/dot-content-drive/portlet/src/lib/components/dot-content-drive-toolbar/dot-content-drive-toolbar.component.ts +++ b/core-web/libs/portlets/dot-content-drive/portlet/src/lib/components/dot-content-drive-toolbar/dot-content-drive-toolbar.component.ts @@ -401,7 +401,11 @@ export class DotContentDriveToolbarComponent { severity: 'info', summary: label, icon: 'pi pi-spin pi-spinner', - sticky: true + sticky: true, + // PrimeNG reads this off the message, not the outlet, and defaults to closable. + // A status is not the reader's to dismiss: it reports work already under way and + // clears itself when that work settles. + closable: false }); }); }); diff --git a/core-web/libs/portlets/dot-content-drive/portlet/src/lib/dot-content-drive-shell/dot-content-drive-shell.component.spec.ts b/core-web/libs/portlets/dot-content-drive/portlet/src/lib/dot-content-drive-shell/dot-content-drive-shell.component.spec.ts index 7ff1181799c6..cca80890f339 100644 --- a/core-web/libs/portlets/dot-content-drive/portlet/src/lib/dot-content-drive-shell/dot-content-drive-shell.component.spec.ts +++ b/core-web/libs/portlets/dot-content-drive/portlet/src/lib/dot-content-drive-shell/dot-content-drive-shell.component.spec.ts @@ -2135,6 +2135,20 @@ describe('DotContentDriveShellComponent', () => { ); }); + it('should count a single file in the singular', () => { + // "Uploading 1 files" is the price of dropping the "(s)" hedge, so the caller picks + // the wording -- it is the only place that knows how many were chosen. + selectUploadType({ + targetFolder: TARGET_FOLDER_DATA, + files: createFileList([createFile('a.png')]), + baseType: 'DOTASSET' + }); + + expect(store.startExternalRun).toHaveBeenCalledWith( + expect.objectContaining({ labelKey: 'content-drive.upload.indicator.one' }) + ); + }); + it('should describe itself as an upload, not as an action applied to a site', () => { // Without a label of its own the run falls to the workflow sentence, which reads // "Applying Upload to demo.dotcms.com" — phrased for an action applied TO content, @@ -2146,7 +2160,9 @@ describe('DotContentDriveShellComponent', () => { }); expect(store.startExternalRun).toHaveBeenCalledWith( - expect.objectContaining({ labelKey: 'content-drive.upload.indicator' }) + expect.objectContaining({ + labelKey: expect.stringContaining('content-drive.upload.indicator') + }) ); }); @@ -2301,8 +2317,14 @@ describe('DotContentDriveShellComponent', () => { // The advisory toast this used to assert is gone: the status it sat beside says // "in the background" itself, and both on screen announced one upload twice. + // Matched as a prefix: what this protects is that one file is still announced as + // backgrounded, not which noun the sentence uses. The wording does vary by count -- + // "1 file" against "3 files" -- and pinning the exact key here would fail for the + // grammar while the behaviour under test was perfectly intact. expect(store.startExternalRun).toHaveBeenCalledWith( - expect.objectContaining({ labelKey: 'content-drive.upload.indicator.background' }) + expect.objectContaining({ + labelKey: expect.stringContaining('content-drive.upload.indicator.background') + }) ); }); @@ -2891,7 +2913,9 @@ describe('DotContentDriveShellComponent', () => { }); expect(store.startExternalRun).toHaveBeenCalledWith( - expect.objectContaining({ labelKey: 'content-drive.upload.indicator.background' }) + expect.objectContaining({ + labelKey: expect.stringContaining('content-drive.upload.indicator.background') + }) ); // Still nothing that names it a success: the files do not exist yet. expect(messageService.add).not.toHaveBeenCalledWith( diff --git a/core-web/libs/portlets/dot-content-drive/portlet/src/lib/dot-content-drive-shell/dot-content-drive-shell.component.ts b/core-web/libs/portlets/dot-content-drive/portlet/src/lib/dot-content-drive-shell/dot-content-drive-shell.component.ts index 92196141818a..be5821012907 100644 --- a/core-web/libs/portlets/dot-content-drive/portlet/src/lib/dot-content-drive-shell/dot-content-drive-shell.component.ts +++ b/core-web/libs/portlets/dot-content-drive/portlet/src/lib/dot-content-drive-shell/dot-content-drive-shell.component.ts @@ -115,6 +115,25 @@ import { } from '../utils/functions'; import { refuseOverCeiling } from '../utils/upload-ceilings'; import { describeUploadFailures } from '../utils/upload-failures'; +/** + * Which wording an upload run names itself with. + * + * The messages spell "file" or "files" out instead of hedging with "file(s)", so the count has to + * choose between them, and only the caller knows the count. Nothing here names a destination: the + * drive already shows where the author is, and the sentence was long enough that the part they + * could read at a glance was getting lost behind the part they could not. + * + * @param {number} count - How many files the batch carries + * @param {{ backgrounded?: boolean }} [options] - Whether the batch is the server's now + * @returns {string} the message key for that run + */ +const uploadIndicatorKey = (count: number, options?: { backgrounded?: boolean }): string => { + const base = options?.backgrounded + ? 'content-drive.upload.indicator.background' + : 'content-drive.upload.indicator'; + + return count === 1 ? `${base}.one` : base; +}; @Component({ selector: 'dot-content-drive-shell', @@ -1545,7 +1564,11 @@ export class DotContentDriveShellComponent implements OnDestroy { // Its own wording rather than the workflow sentence. Without this the run reads // "Applying Upload to demo.dotcms.com" — a phrasing for an action applied TO content, // which is not what putting files INTO a place is. - labelKey: 'content-drive.upload.indicator', + // + // The caller picks singular or plural because it is the only place that knows how + // many files were chosen. The messages spell the noun out rather than hedging with + // "file(s)", which is what the count is for. + labelKey: uploadIndicatorKey(files.length), total: files.length, targetLabel: this.uploadTargetLabel(hostFolder), // Empty on purpose. The indicator speaks only for runs with nothing to mark, since a @@ -1629,7 +1652,7 @@ export class DotContentDriveShellComponent implements OnDestroy { const backgroundRunId = this.#store.startExternalRun({ operation: `${UPLOAD_BATCH_OPERATION}:${event.handle.jobId}`, actionName: this.#dotMessageService.get('content-drive.upload'), - labelKey: 'content-drive.upload.indicator.background', + labelKey: uploadIndicatorKey(submitted, { backgrounded: true }), total: submitted, targetLabel: this.uploadTargetLabel(hostFolder), targets: [] diff --git a/core-web/libs/ui/src/lib/components/dot-status-toast/dot-status-toast.component.html b/core-web/libs/ui/src/lib/components/dot-status-toast/dot-status-toast.component.html index 93d165b71b8f..526c8b6152af 100644 --- a/core-web/libs/ui/src/lib/components/dot-status-toast/dot-status-toast.component.html +++ b/core-web/libs/ui/src/lib/components/dot-status-toast/dot-status-toast.component.html @@ -1,7 +1,9 @@ - - diff --git a/core-web/libs/ui/src/lib/components/dot-status-toast/dot-status-toast.component.scss b/core-web/libs/ui/src/lib/components/dot-status-toast/dot-status-toast.component.scss index 885047acfbfd..52cbe0eb4e0c 100644 --- a/core-web/libs/ui/src/lib/components/dot-status-toast/dot-status-toast.component.scss +++ b/core-web/libs/ui/src/lib/components/dot-status-toast/dot-status-toast.component.scss @@ -7,8 +7,8 @@ // summary kept rendering at the theme's 14px while this file asked for something smaller. ::ng-deep { // Sized to its content, not to a fixed 350px — a two-word outcome should not sit in a slab. - // It grows with the label instead of clipping it, up to a ceiling where it wraps: a status - // naming an unexpected destination is longer than one that does not, and both have to fit. + // It grows with the label instead of clipping it, up to a ceiling where it wraps, because + // what a run has to say is not the same length every time. .p-toast-message { width: fit-content; max-width: min(28rem, calc(100vw - 2rem)); @@ -35,48 +35,26 @@ font-size: 0.8125rem; font-weight: 500; line-height: 1.3; - // Wraps rather than truncates: a destination the user cannot infer is the one thing - // this line exists to say, so cutting it off defeats the point. + // Wraps rather than truncates. The line is short by design, but a caller can still + // hand it more than fits, and a status cut off mid-word says less than no status. overflow-wrap: anywhere; } - .p-toast-message-icon { - display: inline-flex; - align-items: center; - font-size: 1rem; - flex: 0 0 auto; + // PrimeNG renders its own close button unless the MESSAGE carries `closable: false` + // (its template reads `message?.closable !== false`; there is no input on the component to + // set it from here). Callers are told to pass it, and this makes the outlet safe when one + // forgets: a status is never something the reader has to dismiss, so the control must not + // appear whatever was raised. `display: none` also takes it out of the tab order and the + // accessibility tree, rather than leaving a control that can be reached but not seen. + .p-toast-close-button { + display: none; } - // Sits in the row rather than in the corner of a box, which is what makes the pill read as - // one object instead of a panel with a control on it. - .p-toast-close-button { + .p-toast-message-icon { display: inline-flex; align-items: center; - justify-content: center; + font-size: 1rem; flex: 0 0 auto; - align-self: flex-start; - margin-left: 0.25rem; - padding: 0; - width: 1.25rem; - height: 1.25rem; - border: none; - border-radius: 50%; - background: transparent; - color: inherit; - cursor: pointer; - opacity: 0.7; - - &:hover { - opacity: 1; - } - - // `line-height: 1` and a block icon: the glyph otherwise keeps its own line box and - // sits low inside the circle, which reads as a misaligned control. - i { - display: block; - font-size: 0.75rem; - line-height: 1; - } } } } diff --git a/core-web/libs/ui/src/lib/components/dot-status-toast/dot-status-toast.component.spec.ts b/core-web/libs/ui/src/lib/components/dot-status-toast/dot-status-toast.component.spec.ts index 30da9dabea83..fadef9cfdc7c 100644 --- a/core-web/libs/ui/src/lib/components/dot-status-toast/dot-status-toast.component.spec.ts +++ b/core-web/libs/ui/src/lib/components/dot-status-toast/dot-status-toast.component.spec.ts @@ -43,19 +43,14 @@ describe('DotStatusToastComponent', () => { expect(box()?.getAttribute('data-pc-severity') ?? box()?.className).toContain('success'); }); - it('should offer a way to dismiss it', () => { + it('should render nothing a caller can close by hand', () => { + // A status is not a message the reader has to deal with. It reports something already + // under way and goes when that finishes, so a close button asks the reader to tidy up + // after a thing they did not start and cannot affect -- and the row of controls it sat in + // was most of what made this feel like a panel rather than a status. raise({ severity: 'success', summary: 'Uploaded' }); - expect(spectator.query('[data-testid="status-toast-close"]')).toBeTruthy(); - }); - - it('should clear the message when dismissed', () => { - raise({ severity: 'success', summary: 'Uploaded' }); - - spectator.click('[data-testid="status-toast-close"]'); - spectator.detectChanges(); - - expect(spectator.query('[data-testid="status-toast-summary"]')).toBeNull(); + expect(spectator.query('[data-testid="status-toast-close"]')).toBeNull(); }); it('should ignore a detail line rather than growing to fit it', () => { diff --git a/core-web/libs/ui/src/lib/components/dot-status-toast/dot-status-toast.component.ts b/core-web/libs/ui/src/lib/components/dot-status-toast/dot-status-toast.component.ts index 67b0f7df70d9..53bed60ac183 100644 --- a/core-web/libs/ui/src/lib/components/dot-status-toast/dot-status-toast.component.ts +++ b/core-web/libs/ui/src/lib/components/dot-status-toast/dot-status-toast.component.ts @@ -1,10 +1,8 @@ -import { ChangeDetectionStrategy, Component, inject, input } from '@angular/core'; +import { ChangeDetectionStrategy, Component, input } from '@angular/core'; -import { MessageService } from 'primeng/api'; import { ToastModule } from 'primeng/toast'; import type { ToastPositionType } from 'primeng/types/toast'; -import { DotMessagePipe } from '../../dot-message/dot-message.pipe'; import { DotSeverityIconComponent } from '../dot-severity-icon/dot-severity-icon.component'; /** The key this outlet claims by default, so callers and the template cannot drift apart. */ @@ -19,20 +17,22 @@ export const STATUS_TOAST_KEY = 'dot-status'; * outcome in a couple of words, so it is sized to its text and drops `detail` entirely rather than * growing to fit something a caller should not have sent here. * + * It offers no way to dismiss it either. A status reports something already under way and clears + * when whoever raised it says the work is done, so a close button would ask the reader to tidy up + * after a thing they did not start and cannot affect. + * * Colour comes from Lara through the dotCMS preset, keyed on the message severity. The prototype * this follows used a dark pill; that is deliberately not reproduced, because a black surface is * not a pattern in this design system. */ @Component({ selector: 'dot-status-toast', - imports: [ToastModule, DotSeverityIconComponent, DotMessagePipe], + imports: [ToastModule, DotSeverityIconComponent], templateUrl: './dot-status-toast.component.html', styleUrl: './dot-status-toast.component.scss', changeDetection: ChangeDetectionStrategy.OnPush }) export class DotStatusToastComponent { - readonly #messageService = inject(MessageService); - /** Where the stack renders, mirroring `p-toast`'s own positions. */ $position = input('bottom-center', { alias: 'position' }); @@ -45,17 +45,4 @@ export class DotStatusToastComponent { * outlet and no other. */ $key = input(STATUS_TOAST_KEY, { alias: 'key' }); - - /** - * Dismisses one message. - * - * The template supplies its own close button rather than PrimeNG's, so that it sits inside the - * compact row instead of the corner of a box this outlet does not draw. - * - * Clears by key rather than by message: this outlet shows one status at a time, so there is - * nothing to pick between. - */ - protected onClose(): void { - this.#messageService.clear(this.$key()); - } } diff --git a/dotCMS/src/main/webapp/WEB-INF/messages/Language.properties b/dotCMS/src/main/webapp/WEB-INF/messages/Language.properties index 19edc25dced6..80f1b0228e4c 100644 --- a/dotCMS/src/main/webapp/WEB-INF/messages/Language.properties +++ b/dotCMS/src/main/webapp/WEB-INF/messages/Language.properties @@ -7370,8 +7370,10 @@ content-drive.upload.toast.already-uploaded-again=This batch was uploaded before # in-flight fact this flow announces, because it is a rule changing rather than work happening. # The indicator's words for the server phase. "Applying X to Y" would describe an operation the # author has to wait for, which is the opposite of what they were just told. -content-drive.upload.indicator=Uploading {1} file(s) to {0}… -content-drive.upload.indicator.background=Uploading {1} file(s) to {0} in the background… +content-drive.upload.indicator=Uploading {1} files… +content-drive.upload.indicator.one=Uploading {1} file… +content-drive.upload.indicator.background=Uploading {1} files in the background… +content-drive.upload.indicator.background.one=Uploading {1} file in the background… # Refused submissions. The endpoint keeps its two ceilings distinguishable by status, so these # stay two sentences: "fewer files" and "smaller files" are different instructions. content-drive.upload.refused.too-large=That batch is over the upload size limit. Nothing was uploaded, so try again with fewer or smaller files. From 8832db40df1526f33569b7bc3f747f500e9cef02 Mon Sep 17 00:00:00 2001 From: Jalinson Diaz Date: Wed, 16 Sep 2026 18:32:55 -0300 Subject: [PATCH 39/44] refactor(content-drive): a scope bar, and the run reporting where it belongs (#37426) Four things, each of which fell out of the last. **The run is reported by the shell now, not the toolbar.** The toolbar held that effect only because it used to draw the indicator in its own filter row. Once that became a toast the shell renders, a presentational toolbar reaching for `MessageService` to announce portlet-wide state was the wrong way round -- and the shell already says so about the outcome toast a few lines below: it owns the outlet and outlives every dialog a run may have started from. The toolbar keeps `$hasRunInFlight`, which it genuinely needs to disable the Action Center. **A scope bar, replacing the Show System Host chip.** All site content now says what it is showing, with the System Host toggle beside the sentence describing its effect rather than in a row of chips answering a different kind of question. The other two scopes get no bar: the site root is this site's root and System Host is shared content, so neither leaves a sentence anything to qualify. No item count, though the design called for one. The endpoint pages by cursor and returns no total -- the shell's own `$totalItems` overstates it on purpose to keep the paginator's next button alive, which is right there and a lie in a sentence. Better to say nothing than to print a number we cannot stand behind. The bar opens on a fractional grid row rather than an `@if`. `animate.enter` did nothing visible because the element is created and destroyed, so there is no before-state to move from; keeping it mounted and transitioning a property is what the sidebar does and why the sidebar's animation works. `0fr` to `1fr` rather than a max-height, which needs a guessed ceiling and finishes the visible motion part way through the duration. The switch is sized through `--p-toggleswitch-width` and friends. A `size="small"` input exists on the component but does nothing here: the dotCMS Lara preset defines no small variant, so no class is emitted and no `-sm-` token exists. **Dead code, most of it created by this session's own decisions.** Dropping the destination from the upload copy orphaned `targetLabel`, which was read in exactly one place and passed as an argument four messages ignore -- so `escapeHtml`, `uploadTargetLabel` and five call sites went with it. Deleting the unreachable "Applying X to Y" branches orphaned `actionName` on the run and its nine call sites. `processed` and `updateExternalRun` were a progress mechanism nothing could feed: the app's fetch backend reports no upload progress and the XHR one that would is deprecated. Also `$actionExecutionPercent` and `withSidebar`'s `selectRootNode`, neither with a caller. `actionName` needed care rather than a grep: the *result* type has one of its own, destructured for the outcome toast, and a looser sweep would have taken both and broken every completion message. tsc found the nine run call sites; a search for `.actionName` had missed them all, because they are destructured. **Placement.** `uploadIndicatorKey` moves out of the shell component into `utils/functions.ts`, and `WorkflowFilterEntry` and `FolderTreeHierarchyLevel` out of `utils/functions.ts` into `shared/models.ts`. Functions in a component file and types in a functions file; both had a home already. Also here: the integration test asserted `javax.ws.rs.BadRequestException` while the form throws `com.dotcms.rest.exception.BadRequestException`. Siblings, not subtypes, so it compiled and would have failed the moment it first ran. Caught in review by @ihoffmann-dot, not by a build -- that class has still never executed anywhere. The unit twin catches broad `RuntimeException` and asserts the message, which is defensible on its own and is why nothing was checking the type. Co-Authored-By: Claude Opus 5 (1M context) --- .../src/pages/contentDrive.page.ts | 26 +++ .../content-drive-browse-scopes.spec.ts | 55 +++++ ...dot-content-drive-scope-bar.component.html | 19 ++ ...-content-drive-scope-bar.component.spec.ts | 75 +++++++ .../dot-content-drive-scope-bar.component.ts | 80 +++++++ ...content-drive-workflow-filter.component.ts | 7 +- .../dot-content-drive-toolbar.component.html | 15 -- ...ot-content-drive-toolbar.component.spec.ts | 204 +----------------- .../dot-content-drive-toolbar.component.ts | 174 +-------------- ...folder-list-context-menu.component.spec.ts | 64 ------ .../dot-folder-list-context-menu.component.ts | 10 - .../dot-content-drive-shell.component.html | 30 ++- .../dot-content-drive-shell.component.spec.ts | 168 +++++++++++---- .../dot-content-drive-shell.component.ts | 201 ++++++++++++----- .../portlet/src/lib/shared/models.ts | 44 ++-- .../lib/store/dot-content-drive.store.spec.ts | 10 +- .../withActionExecution.spec.ts | 7 +- .../action-execution/withActionExecution.ts | 25 --- .../lib/store/features/sidebar/withSidebar.ts | 16 +- .../portlet/src/lib/utils/functions.ts | 49 ++--- .../WEB-INF/messages/Language.properties | 11 +- .../v1/drive/ContentDriveBrowseScopeTest.java | 7 +- 22 files changed, 630 insertions(+), 667 deletions(-) create mode 100644 core-web/libs/portlets/dot-content-drive/portlet/src/lib/components/dot-content-drive-scope-bar/dot-content-drive-scope-bar.component.html create mode 100644 core-web/libs/portlets/dot-content-drive/portlet/src/lib/components/dot-content-drive-scope-bar/dot-content-drive-scope-bar.component.spec.ts create mode 100644 core-web/libs/portlets/dot-content-drive/portlet/src/lib/components/dot-content-drive-scope-bar/dot-content-drive-scope-bar.component.ts diff --git a/core-web/apps/dotcms-ui-e2e/src/pages/contentDrive.page.ts b/core-web/apps/dotcms-ui-e2e/src/pages/contentDrive.page.ts index c4be2ce5dfdf..47d5e4ded769 100644 --- a/core-web/apps/dotcms-ui-e2e/src/pages/contentDrive.page.ts +++ b/core-web/apps/dotcms-ui-e2e/src/pages/contentDrive.page.ts @@ -33,6 +33,9 @@ export class ContentDrivePage { readonly searchField: Locator; readonly statusToast: Locator; readonly statusToastSummary: Locator; + readonly scopeBar: Locator; + readonly scopeBarSummary: Locator; + readonly scopeBarToggle: Locator; readonly toasts: Locator; constructor(private page: Page) { @@ -56,6 +59,12 @@ export class ContentDrivePage { // is gone, so anything still looking for it is asserting on a testid that cannot appear. this.statusToast = page.getByTestId('dot-status-toast'); this.statusToastSummary = page.getByTestId('status-toast-summary'); + // The bar above the listing: what is being shown, and the one control that changes it. + // Its slot is always in the DOM and opens by height, so visibility is the question to ask + // rather than presence. + this.scopeBar = page.getByTestId('scope-bar'); + this.scopeBarSummary = page.getByTestId('scope-bar-summary'); + this.scopeBarToggle = page.getByTestId('scope-bar-toggle'); this.toasts = page.locator('.p-toast-message'); } @@ -327,6 +336,23 @@ export class ContentDrivePage { await expect(this.statusToastSummary).toHaveCount(0, { timeout: OUTCOME_TIMEOUT }); } + /** Whether the scope bar is open, which is a question about height rather than presence. */ + async scopeBarIsOpen(): Promise { + const slot = this.page.getByTestId('scope-bar-slot'); + const box = await slot.boundingBox(); + + return (box?.height ?? 0) > 0; + } + + /** Flips the System Host toggle and waits for the listing it re-requests. */ + async toggleSystemHostInScopeBar() { + const listing = this.page.waitForResponse( + (response) => response.url().includes('/v1/drive/search') && response.ok() + ); + await this.scopeBarToggle.click(); + await listing; + } + /** Opens the New menu and returns the labels it offers. */ async openNewMenu(): Promise { await this.toolbar.getByTestId('add-new-button').click(); diff --git a/core-web/apps/dotcms-ui-e2e/src/tests/content-drive/content-drive-browse-scopes.spec.ts b/core-web/apps/dotcms-ui-e2e/src/tests/content-drive/content-drive-browse-scopes.spec.ts index e34472bdaea3..8efc23b88bd8 100644 --- a/core-web/apps/dotcms-ui-e2e/src/tests/content-drive/content-drive-browse-scopes.spec.ts +++ b/core-web/apps/dotcms-ui-e2e/src/tests/content-drive/content-drive-browse-scopes.spec.ts @@ -246,4 +246,59 @@ test.describe('Content Drive Browse Scopes', () => { await apiHelpers.deleteFolders(site.hostname, [`/${folderName}`]); } }); + + test('says what all site content is showing, and only there @critical', async ({ + adminPage + }) => { + // The bar carries the sentence and the System Host toggle that used to be a chip in the + // filter row. Only all site content gets one: the site root is this site's root and + // nothing else, and System Host is shared content and nothing else, so neither leaves a + // sentence anything to qualify. + const drive = new ContentDrivePage(adminPage); + await drive.goTo(); + + await drive.selectAllSiteContent(); + await expect(drive.scopeBar).toBeVisible(); + await expect(drive.scopeBarToggle).toBeVisible(); + + await drive.selectSystemHost(); + expect(await drive.scopeBarIsOpen()).toBe(false); + }); + + test('flips the sentence with the System Host toggle @critical', async ({ adminPage }) => { + // The sentence and the switch are one statement: if the toggle can say "included" while + // the words say "excluded", the bar is worse than no bar. + const drive = new ContentDrivePage(adminPage); + await drive.goTo(); + await drive.selectAllSiteContent(); + + const before = (await drive.scopeBarSummary.innerText()).trim(); + await drive.toggleSystemHostInScopeBar(); + const after = (await drive.scopeBarSummary.innerText()).trim(); + + expect(after).not.toBe(before); + // Whichever way round the run starts, the pair must be the two halves of the same choice. + expect([before, after].sort()).toEqual( + [ + 'All Files in site (System Host shared files excluded)', + 'All Files in site (System Host shared files included)' + ].sort() + ); + }); + + test('carries the toggle into the URL so a reload keeps it @critical', async ({ + adminPage + }) => { + // The filter is written either way rather than cleared, so the applied state is spelled + // out rather than implied by an absent key that happens to read as on. + const drive = new ContentDrivePage(adminPage); + await drive.goTo(); + await drive.selectAllSiteContent(); + + await drive.toggleSystemHostInScopeBar(); + const summary = (await drive.scopeBarSummary.innerText()).trim(); + + await adminPage.reload(); + await expect(drive.scopeBarSummary).toHaveText(summary); + }); }); diff --git a/core-web/libs/portlets/dot-content-drive/portlet/src/lib/components/dot-content-drive-scope-bar/dot-content-drive-scope-bar.component.html b/core-web/libs/portlets/dot-content-drive/portlet/src/lib/components/dot-content-drive-scope-bar/dot-content-drive-scope-bar.component.html new file mode 100644 index 000000000000..3251070218be --- /dev/null +++ b/core-web/libs/portlets/dot-content-drive/portlet/src/lib/components/dot-content-drive-scope-bar/dot-content-drive-scope-bar.component.html @@ -0,0 +1,19 @@ +
+ + + {{ $summaryKey() | dm }} + + + + +
diff --git a/core-web/libs/portlets/dot-content-drive/portlet/src/lib/components/dot-content-drive-scope-bar/dot-content-drive-scope-bar.component.spec.ts b/core-web/libs/portlets/dot-content-drive/portlet/src/lib/components/dot-content-drive-scope-bar/dot-content-drive-scope-bar.component.spec.ts new file mode 100644 index 000000000000..f2e1e0c330db --- /dev/null +++ b/core-web/libs/portlets/dot-content-drive/portlet/src/lib/components/dot-content-drive-scope-bar/dot-content-drive-scope-bar.component.spec.ts @@ -0,0 +1,75 @@ +import { Spectator, createComponentFactory, mockProvider } from '@openng/spectator/vitest'; +import { vi } from 'vitest'; + +import { signal } from '@angular/core'; + +import { DotMessageService } from '@dotcms/data-access'; +import { SHARED_ASSETS_DISABLED_VALUE, SHARED_ASSETS_FILTER_KEY } from '@dotcms/ui'; +import { MockDotMessageService } from '@dotcms/utils-testing'; + +import { DotContentDriveScopeBarComponent } from './dot-content-drive-scope-bar.component'; + +import { DotContentDriveStore } from '../../store/dot-content-drive.store'; + +describe('DotContentDriveScopeBarComponent', () => { + let spectator: Spectator; + + const filtersSignal = signal>({}); + + const createComponent = createComponentFactory({ + component: DotContentDriveScopeBarComponent, + componentProviders: [ + mockProvider(DotContentDriveStore, { + filters: filtersSignal, + getFilterValue: vi.fn((key: string) => filtersSignal()[key]), + patchFilters: vi.fn() + }) + ], + providers: [ + { + provide: DotMessageService, + useValue: new MockDotMessageService({ + 'content-drive.scope-bar.all-site-content.excluded': + 'All Files in site (System Host shared files excluded)', + 'content-drive.scope-bar.all-site-content.included': + 'All Files in site (System Host shared files included)', + 'content-drive.scope-bar.include-system-host': 'Include System Host:', + 'content-drive.scope-bar.off': 'Off' + }) + } + ] + }); + + beforeEach(() => { + filtersSignal.set({}); + spectator = createComponent(); + }); + + const summary = () => spectator.query('[data-testid="scope-bar-summary"]')?.textContent ?? ''; + + it('should say shared files are included while the toggle is on', () => { + // On is the default everywhere: the endpoint's form defaults the flag to true and an + // absent key reads as on, so the sentence has to agree with that rather than with silence. + expect(summary()).toContain('included'); + }); + + it('should say shared files are excluded once the toggle is off', () => { + filtersSignal.set({ [SHARED_ASSETS_FILTER_KEY]: SHARED_ASSETS_DISABLED_VALUE }); + spectator.detectChanges(); + + expect(summary()).toContain('excluded'); + }); + + it('should write the filter when the toggle is flipped', () => { + // The control the toolbar chip used to be. It writes the state either way rather than + // clearing the key, so the applied filter is spelled out in the URL instead of implied by + // an absence that reads as on. + const store = spectator.inject(DotContentDriveStore, true); + + spectator.click('[data-testid="scope-bar-toggle"] input'); + + expect(store.patchFilters).toHaveBeenCalledWith({ + [SHARED_ASSETS_FILTER_KEY]: SHARED_ASSETS_DISABLED_VALUE + }); + }); +}); diff --git a/core-web/libs/portlets/dot-content-drive/portlet/src/lib/components/dot-content-drive-scope-bar/dot-content-drive-scope-bar.component.ts b/core-web/libs/portlets/dot-content-drive/portlet/src/lib/components/dot-content-drive-scope-bar/dot-content-drive-scope-bar.component.ts new file mode 100644 index 000000000000..fac052e6ae1f --- /dev/null +++ b/core-web/libs/portlets/dot-content-drive/portlet/src/lib/components/dot-content-drive-scope-bar/dot-content-drive-scope-bar.component.ts @@ -0,0 +1,80 @@ +import { ChangeDetectionStrategy, Component, computed, inject } from '@angular/core'; +import { FormsModule } from '@angular/forms'; + +import { ToggleSwitchModule } from 'primeng/toggleswitch'; + +import { + DotMessagePipe, + SHARED_ASSETS_DISABLED_VALUE, + SHARED_ASSETS_ENABLED_VALUE, + SHARED_ASSETS_FILTER_KEY +} from '@dotcms/ui'; + +import { DotContentDriveStore } from '../../store/dot-content-drive.store'; + +/** + * Says what the listing is showing, and carries the one control that changes it. + * + * Only all site content gets a bar. The other two scopes answer the question it asks by being + * chosen: the site root is this site's root and nothing else, and System Host is shared content and + * nothing else — so there is nothing left for a sentence to qualify or a toggle to decide. + * + * The toggle replaced the `Show System Host` chip that used to sit in the filter row. Same filter, + * same values; what changes is that the control now sits beside the sentence describing its effect, + * rather than in a row of chips that answer a different kind of question. + */ +@Component({ + selector: 'dot-content-drive-scope-bar', + imports: [DotMessagePipe, ToggleSwitchModule, FormsModule], + templateUrl: './dot-content-drive-scope-bar.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, + host: { class: 'block' }, + styles: [ + ` + /* A "small" size input on p-toggleswitch does nothing here: the dotCMS Lara preset + defines no small variant, so no p-toggleswitch-sm class is emitted and no + --p-toggleswitch-sm-* token exists to pick up. The size is set through the tokens the + theme DOES define, scoped to this bar so no other switch in the app changes. + + Two thirds of the default (3rem / 1.75rem / 1.25rem): this control annotates a + sentence rather than sitting in a form, so it should read as part of the line. */ + :host { + --p-toggleswitch-width: 2rem; + --p-toggleswitch-height: 1.125rem; + --p-toggleswitch-handle-size: 0.75rem; + } + ` + ] +}) +export class DotContentDriveScopeBarComponent { + readonly #store = inject(DotContentDriveStore); + + /** + * Whether shared content is in the listing. + * + * "Off only when explicitly off": the endpoint's own form defaults the flag to true and an + * absent key reads as on, so anything other than the disabled value means included. + */ + protected readonly $includesSystemHost = computed( + () => this.#store.getFilterValue(SHARED_ASSETS_FILTER_KEY) !== SHARED_ASSETS_DISABLED_VALUE + ); + + /** The sentence, which has to agree with the toggle rather than describe a fixed scope. */ + protected readonly $summaryKey = computed(() => + this.$includesSystemHost() + ? 'content-drive.scope-bar.all-site-content.included' + : 'content-drive.scope-bar.all-site-content.excluded' + ); + + /** + * Writes the state either way rather than clearing the key, so the applied filter is spelled + * out in the URL instead of being implied by an absence that happens to read as on. + */ + protected onToggle(): void { + this.#store.patchFilters({ + [SHARED_ASSETS_FILTER_KEY]: this.$includesSystemHost() + ? SHARED_ASSETS_DISABLED_VALUE + : SHARED_ASSETS_ENABLED_VALUE + }); + } +} diff --git a/core-web/libs/portlets/dot-content-drive/portlet/src/lib/components/dot-content-drive-toolbar/components/dot-content-drive-workflow-filter/dot-content-drive-workflow-filter.component.ts b/core-web/libs/portlets/dot-content-drive/portlet/src/lib/components/dot-content-drive-toolbar/components/dot-content-drive-workflow-filter/dot-content-drive-workflow-filter.component.ts index cce2f6156468..925394a43a18 100644 --- a/core-web/libs/portlets/dot-content-drive/portlet/src/lib/components/dot-content-drive-toolbar/components/dot-content-drive-workflow-filter/dot-content-drive-workflow-filter.component.ts +++ b/core-web/libs/portlets/dot-content-drive/portlet/src/lib/components/dot-content-drive-toolbar/components/dot-content-drive-workflow-filter/dot-content-drive-workflow-filter.component.ts @@ -34,11 +34,8 @@ import { } from '@dotcms/ui'; import { PANEL_SCROLL_HEIGHT } from '../../../../shared/constants'; -import { - parseWorkflowToken, - workflowEntryToToken, - WorkflowFilterEntry -} from '../../../../utils/functions'; +import { WorkflowFilterEntry } from '../../../../shared/models'; +import { parseWorkflowToken, workflowEntryToToken } from '../../../../utils/functions'; /** * One selected scheme, optionally pinned to a single step. `step` omitted means diff --git a/core-web/libs/portlets/dot-content-drive/portlet/src/lib/components/dot-content-drive-toolbar/dot-content-drive-toolbar.component.html b/core-web/libs/portlets/dot-content-drive/portlet/src/lib/components/dot-content-drive-toolbar/dot-content-drive-toolbar.component.html index fc7d8915dce6..8100be3dc1f8 100644 --- a/core-web/libs/portlets/dot-content-drive/portlet/src/lib/components/dot-content-drive-toolbar/dot-content-drive-toolbar.component.html +++ b/core-web/libs/portlets/dot-content-drive/portlet/src/lib/components/dot-content-drive-toolbar/dot-content-drive-toolbar.component.html @@ -78,21 +78,6 @@ than named by a registry, which is what lets the two portlet-local ones — Workflow and the field filters — sit in the same row as the shared ones. --> - - @if ($allSiteContentSelected()) { - - } +
+ +
+ +
+
+ + class="col-start-2 row-start-4 overflow-auto"> @@ -78,7 +102,7 @@
+ class="col-start-3 row-span-3 row-start-2 flex w-0 flex-col items-center justify-center font-semibold"> { // Reactive so the shell's $extraColumns computed recomputes when the fields change. let showInListFieldsSignal: WritableSignal; + // The run the toolbar surface speaks for, driven from here so the shell's status-toast effect + // can be exercised. Only unmarked runs reach it; the store's own computed does that filtering. + const toolbarRunSignal = signal(undefined); + const toolbarRunCountSignal = signal(0); + const createComponent = createComponentFactory({ component: DotContentDriveShellComponent, providers: [ @@ -268,8 +278,8 @@ describe('DotContentDriveShellComponent', () => { trackUploadJob: vi.fn(), updateExternalRun: vi.fn(), activeRunCount: signal(0), - toolbarRun: signal(undefined), - toolbarRunCount: signal(0), + toolbarRun: toolbarRunSignal, + toolbarRunCount: toolbarRunCountSignal, busyRows: signal([]), endExternalRun: vi.fn(), setPagination: vi.fn(), @@ -399,6 +409,12 @@ describe('DotContentDriveShellComponent', () => { router = spectator.inject(Router); location = spectator.inject(Location); messageService = spectator.inject(MessageService); + + // Module-level, so whatever the last test left here is what the next one starts with. The + // shell raises the status toast off these, which made an unrelated test see a message it + // never asked for. + toolbarRunSignal.set(undefined); + toolbarRunCountSignal.set(0); uploadService = spectator.inject(DotUploadFileService); routerService = spectator.inject(DotRouterService); dotMessageService = spectator.inject(DotMessageService); @@ -2036,6 +2052,100 @@ describe('DotContentDriveShellComponent', () => { }); }); + describe('reporting a run in flight', () => { + // Moved here with the effect itself. The toolbar raised this while it still drew the + // indicator in its filter row; now that the status is a toast rendered by the shell, the + // shell owns raising it -- it holds the outlet and outlives every dialog the run may have + // started from. + const statusMessages = () => + (messageService.add as unknown as { mock: { calls: unknown[][] } }).mock.calls + .map(([message]) => message as { key?: string; summary?: string }) + .filter((message) => message.key === STATUS_TOAST_KEY); + + it('should raise nothing while nothing is running', () => { + spectator.detectChanges(); + spectator.flushEffects(); + + expect(statusMessages()).toHaveLength(0); + }); + + it('should report a run that brought its own wording', () => { + toolbarRunCountSignal.set(1); + toolbarRunSignal.set({ + actionName: 'Upload', + total: 3, + labelKey: 'content-drive.upload.indicator' + } as DotContentDriveActionExecution); + spectator.detectChanges(); + spectator.flushEffects(); + + expect(statusMessages()).toHaveLength(1); + }); + + it('should stay silent for a run that brought none', () => { + // Only unmarked runs arrive here, and every one of those is an upload, which names + // itself. Anything else is a run nobody wrote words for, and inventing "Applying X to + // Y" for it is what made an upload read "Applying Upload to demo.dotcms.com". + toolbarRunCountSignal.set(1); + toolbarRunSignal.set({ + actionName: 'Publish', + total: 3 + } as DotContentDriveActionExecution); + spectator.detectChanges(); + spectator.flushEffects(); + + expect(statusMessages()).toHaveLength(0); + }); + + it('should clear the status once the run settles', () => { + toolbarRunCountSignal.set(1); + toolbarRunSignal.set({ + actionName: 'Upload', + total: 3, + labelKey: 'content-drive.upload.indicator' + } as DotContentDriveActionExecution); + spectator.detectChanges(); + spectator.flushEffects(); + + toolbarRunCountSignal.set(0); + toolbarRunSignal.set(undefined); + spectator.detectChanges(); + spectator.flushEffects(); + + // Sticky on purpose, so nothing times it out mid-upload. That makes ending it the + // store's job, which is the half worth pinning. + expect(messageService.clear).toHaveBeenCalledWith(STATUS_TOAST_KEY); + }); + + it('should raise it as something the reader cannot dismiss', () => { + toolbarRunCountSignal.set(1); + toolbarRunSignal.set({ + actionName: 'Upload', + total: 3, + labelKey: 'content-drive.upload.indicator' + } as DotContentDriveActionExecution); + spectator.detectChanges(); + spectator.flushEffects(); + + // PrimeNG reads `closable` off the message, not the outlet, so this is the only place + // that can turn the close button off for real. + expect(statusMessages()[0]).toEqual( + expect.objectContaining({ sticky: true, closable: false }) + ); + }); + + it('should collapse to a count when several runs are in flight', () => { + // With several at once the store leaves the run undefined on purpose: name none of + // them and report the number instead. + toolbarRunCountSignal.set(3); + toolbarRunSignal.set(undefined); + spectator.detectChanges(); + spectator.flushEffects(); + + expect(statusMessages()).toHaveLength(1); + }); + }); + describe('upload — a batch of files', () => { beforeEach(() => { spectator.detectChanges(); @@ -2098,43 +2208,6 @@ describe('DotContentDriveShellComponent', () => { ); }); - it('should name the site when no folder is chosen', () => { - // The site, not the root of it. Naming the root was tried and read as fussy for what - // it bought: the destination that matters to the author is which site received the - // files, and the site row and the flat view put them in the same place anyway. - store.currentSite.mockReturnValue(MOCK_SITES[0]); - store.$systemHostSelected.mockReturnValue(false); - - selectUploadType({ - targetFolder: undefined, - files: createFileList([createFile('a.png')]), - baseType: 'DOTASSET' - }); - - expect(store.startExternalRun).toHaveBeenCalledWith( - expect.objectContaining({ targetLabel: MOCK_SITES[0].hostname }) - ); - }); - - it('should name System Host as the upload destination, not the switcher site', () => { - // The indicator is the only surface an upload has before the handle comes back, so a - // wrong destination there tells the author their files are going somewhere else. - store.currentSite.mockReturnValue(MOCK_SITES[0]); - store.$systemHostSelected.mockReturnValue(true); - - selectUploadType({ - targetFolder: undefined, - files: createFileList([createFile('a.png')]), - baseType: 'DOTASSET' - }); - - // The message mock echoes keys, so the key IS the observable outcome here: the - // indicator asks for the sidebar entry's own label rather than naming a site. - expect(store.startExternalRun).toHaveBeenCalledWith( - expect.objectContaining({ targetLabel: 'content-drive.sidebar.system-host' }) - ); - }); - it('should count a single file in the singular', () => { // "Uploading 1 files" is the price of dropping the "(s)" hedge, so the caller picks // the wording -- it is the only place that knows how many were chosen. @@ -3035,10 +3108,17 @@ describe('DotContentDriveShellComponent', () => { baseType: 'DOTASSET' }); - // Nothing at all now. The handoff advisory this used to assert was removed because - // the status toast beside it already said the upload was in the background, and one - // upload announcing itself twice is the noise this replaced. - expect(addSpy).not.toHaveBeenCalled(); + // Nothing on the wide outlet. The handoff advisory this used to assert was removed + // because the status toast beside it already said the upload was in the background, + // and one upload announcing itself twice is the noise this replaced. + // + // Keyed rather than absolute: the status toast is raised by this same service, and it + // is the one thing that SHOULD appear while a run is in flight. + const wideOutletMessages = addSpy.mock.calls + .map(([message]) => message as { key?: string }) + .filter((message) => message.key !== STATUS_TOAST_KEY); + + expect(wideOutletMessages).toEqual([]); }); it('should not announce an upload the listing now shows', () => { diff --git a/core-web/libs/portlets/dot-content-drive/portlet/src/lib/dot-content-drive-shell/dot-content-drive-shell.component.ts b/core-web/libs/portlets/dot-content-drive/portlet/src/lib/dot-content-drive-shell/dot-content-drive-shell.component.ts index be5821012907..30def424a4c7 100644 --- a/core-web/libs/portlets/dot-content-drive/portlet/src/lib/dot-content-drive-shell/dot-content-drive-shell.component.ts +++ b/core-web/libs/portlets/dot-content-drive/portlet/src/lib/dot-content-drive-shell/dot-content-drive-shell.component.ts @@ -69,12 +69,14 @@ import { DotStatusToastComponent, DotToastComponent, DotUploadDropzoneComponent, - DotUploadTypeSelectorComponent + DotUploadTypeSelectorComponent, + STATUS_TOAST_KEY } from '@dotcms/ui'; import { DotContentDriveActionCenterComponent } from '../components/dialogs/dot-content-drive-action-center/dot-content-drive-action-center.component'; import { DotContentDriveDialogContentTypeSelectorComponent } from '../components/dialogs/dot-content-drive-dialog-content-type-selector/dot-content-drive-dialog-content-type-selector.component'; import { DotContentDriveDialogFolderComponent } from '../components/dialogs/dot-content-drive-dialog-folder/dot-content-drive-dialog-folder.component'; +import { DotContentDriveScopeBarComponent } from '../components/dot-content-drive-scope-bar/dot-content-drive-scope-bar.component'; import { DotContentDriveSidebarComponent } from '../components/dot-content-drive-sidebar/dot-content-drive-sidebar.component'; import { DotContentDriveToolbarComponent } from '../components/dot-content-drive-toolbar/dot-content-drive-toolbar.component'; import { DotFolderListViewContextMenuComponent } from '../components/dot-folder-list-context-menu/dot-folder-list-context-menu.component'; @@ -111,30 +113,11 @@ import { isFolder, browsedFolderRef, normalizeFolderRef, - toFolderRef + toFolderRef, + uploadIndicatorKey } from '../utils/functions'; import { refuseOverCeiling } from '../utils/upload-ceilings'; import { describeUploadFailures } from '../utils/upload-failures'; -/** - * Which wording an upload run names itself with. - * - * The messages spell "file" or "files" out instead of hedging with "file(s)", so the count has to - * choose between them, and only the caller knows the count. Nothing here names a destination: the - * drive already shows where the author is, and the sentence was long enough that the part they - * could read at a glance was getting lost behind the part they could not. - * - * @param {number} count - How many files the batch carries - * @param {{ backgrounded?: boolean }} [options] - Whether the batch is the server's now - * @returns {string} the message key for that run - */ -const uploadIndicatorKey = (count: number, options?: { backgrounded?: boolean }): string => { - const base = options?.backgrounded - ? 'content-drive.upload.indicator.background' - : 'content-drive.upload.indicator'; - - return count === 1 ? `${base}.one` : base; -}; - @Component({ selector: 'dot-content-drive-shell', imports: [ @@ -156,7 +139,8 @@ const uploadIndicatorKey = (count: number, options?: { backgrounded?: boolean }) DotToastComponent, DotEditContentSidePanelComponent, ProgressSpinnerModule, - DotContentDriveActionCenterComponent + DotContentDriveActionCenterComponent, + DotContentDriveScopeBarComponent ], providers: [ DotContentDriveStore, @@ -187,7 +171,7 @@ const uploadIndicatorKey = (count: number, options?: { backgrounded?: boolean }) templateUrl: './dot-content-drive-shell.component.html', changeDetection: ChangeDetectionStrategy.OnPush, host: { - class: 'grid relative h-full grid-cols-[min-content_1fr_min-content] grid-rows-[min-content_min-content_1fr]', + class: 'grid relative h-full grid-cols-[min-content_1fr_min-content] grid-rows-[min-content_min-content_min-content_1fr]', // Bound here rather than with addEventListener: Angular unbinds it when the shell is // destroyed. A hand-rolled window listener outlives the portlet unless every teardown path // remembers to remove it, and then a stale closure keeps guarding the page on a count that @@ -253,6 +237,15 @@ export class DotContentDriveShellComponent implements OnDestroy { */ readonly $treeExpanded = this.#store.isTreeVisuallyExpanded; + /** + * Whether the scope bar has anything to say. + * + * Only all site content gets one: the site root and System Host each answer the question the + * bar asks simply by being chosen, leaving nothing for its sentence to qualify or its toggle to + * decide. + */ + readonly $allSiteContentSelected = this.#store.$allSiteContentSelected; + /** * Folder a dropped file lands in. The shared dropzone is presentational, so the target comes * from here rather than the dropzone reaching into the store itself. @@ -689,6 +682,132 @@ export class DotContentDriveShellComponent implements OnDestroy { .map(normalizeFolderRef) .includes(browsedFolderRef(this.#store.currentSite()?.hostname, this.#store.path())); + /** + * The action currently being applied, surfaced here because the run outlives the Action Center + * dialog. Once the user closes that dialog the toolbar is the only place still reporting the run, + * so without this the work would continue with no indication until the completion toast fired. + */ + readonly $actionExecution = this.#store.toolbarRun; + + /** + * How many runs are in flight. With several at once the store leaves the run undefined on + * purpose, so keying anything off the run alone would go quiet exactly when the most is + * happening. + */ + readonly $activeRunCount = this.#store.toolbarRunCount; + + readonly $hasRunInFlight = computed(() => this.$activeRunCount() > 0); + + /** + * Resolved indicator label. Built here rather than in the template because `DotMessagePipe` takes + * `string[]` arguments and the item count is a number. + * + * The action name is escaped because this label is bound with `[innerHTML]` — the message itself + * carries a ``, which is the only reason it is not plain interpolation. For a workflow action + * that name is `WorkflowAction.name` straight from the backend, so without this a name containing + * markup becomes real DOM. Angular's sanitizer already drops event-handler attributes, so this is + * not an XSS fix; what it stops is structural injection that survives sanitizing — an `` + * pointing at an arbitrary URL, a link, or markup that simply breaks the toolbar's layout. + */ + readonly $actionExecutionLabel = computed(() => { + const execution = this.$actionExecution(); + + // Several at once: name none of them and report the number instead (FR-017). + if (!execution) { + return this.$activeRunCount() > 1 + ? this.#dotMessageService.get( + 'content-drive.action-center.applying-many', + String(this.$activeRunCount()) + ) + : ''; + } + + // A run says whatever it brought, and nothing otherwise. + // + // There used to be an "Applying X to Y" fallback here for runs with no copy of their own. + // Nothing could reach it: only an *unmarked* run arrives here (`toolbarRun` filters to + // `targets.length === 0`, because a run whose rows are marked in the grid is already + // telling the author where it is), and every unmarked run is an upload, which names + // itself. Its one real effect was on uploads before they had their own words, where it + // produced "Applying Upload to demo.dotcms.com" -- a sentence for an action performed ON + // content rather than for files going INTO a place. + // + // So a run arriving with no `labelKey` is one nobody has written words for, and inventing + // some is what caused that. Silence is the honest answer, and the effect above raises + // nothing for an empty label. + // + // The count is the only thing interpolated. It is a number this code produced, so nothing + // here needs escaping even though the message carries its own `` and is therefore bound + // with `[innerHTML]`. Anything author-written that is ever added to these messages does. + return execution.labelKey + ? this.#dotMessageService.get(execution.labelKey, String(execution.total)) + : ''; + }); + + /** What the status toast is currently saying, so an unchanged run is not re-raised. */ + #shownRunLabel: string | undefined; + + /** + * Mirrors the run in flight into the status toast. + * + * The toolbar used to draw this itself, at the end of the filter row. It moved because the row + * is where the user works — filter chips come and go beside it — and a status that appears and + * disappears there shifts the controls under the pointer. A toast says the same thing without + * competing for that space, and gives the in-flight state and its outcome one surface instead + * of an indicator here and a toast elsewhere. + * + * Sticky while the run lasts and cleared when it settles: the outcome toast that follows is + * raised by the shell, which is where results are turned into copy. + * + * The percentage the old indicator could show is deliberately not carried over. Nothing ever + * sets a run's `processed` — `updateExternalRun` has no callers — so it could not render, and + * the app's HTTP backend does not report upload progress either. + */ + readonly runToastSync = effect(() => { + const running = this.$hasRunInFlight(); + const label = this.$actionExecutionLabel(); + + untracked(() => { + if (!running) { + this.#shownRunLabel = undefined; + this.#messageService.clear(STATUS_TOAST_KEY); + + return; + } + + // A run with nothing to say raises nothing rather than an empty pill. + if (!label) { + this.#shownRunLabel = undefined; + this.#messageService.clear(STATUS_TOAST_KEY); + + return; + } + + // Only when the wording actually changes. PrimeNG has no update, so re-reporting means + // clearing and raising again — and the old inline indicator simply changed its text, + // so re-animating on every store touch would be a behaviour this replaced, not kept. + // The label does change while runs are in flight: a second run starting collapses it to + // the count form, and finishing brings the named form back. + if (label === this.#shownRunLabel) { + return; + } + + this.#shownRunLabel = label; + this.#messageService.clear(STATUS_TOAST_KEY); + this.#messageService.add({ + key: STATUS_TOAST_KEY, + severity: 'info', + summary: label, + icon: 'pi pi-spin pi-spinner', + sticky: true, + // PrimeNG reads this off the message, not the outlet, and defaults to closable. + // A status is not the reader's to dismiss: it reports work already under way and + // clears itself when that work settles. + closable: false + }); + }); + }); + /** * Reports a finished workflow action as a toast, refreshes the grid, and closes the dialog if it * is still open. @@ -1496,32 +1615,6 @@ export class DotContentDriveShellComponent implements OnDestroy { * @param {DotFolderTreeNodeData} [hostFolder] * @memberof DotContentDriveShellComponent */ - /** - * What the progress indicator calls an upload's destination. - * - * `||`, not `??`: the site root's node carries an *empty* path, which is present but names - * nothing, so the indicator would otherwise read "Applying Upload to " with a blank target. - * - * With no folder the destination is whichever host the sidebar is showing — and in the System - * Host scope that is not the site in the switcher, which still names one. Saying the site's - * name there tells the author their files are going somewhere they are not. The name is the - * sidebar entry's own label, so the indicator and the row the user clicked agree. - */ - private uploadTargetLabel(hostFolder?: { path?: string }): string | undefined { - if (hostFolder?.path) { - return hostFolder.path; - } - - if (this.#store.$systemHostSelected()) { - return this.#dotMessageService.get('content-drive.sidebar.system-host'); - } - - // The site. With no folder chosen the files land at its root, but which SITE received them - // is the part an author needs, and the site row and the flat view put content in the same - // place anyway -- so naming the root adds words without adding an answer. - return this.#store.currentSite()?.hostname; - } - protected uploadByBaseType( files: File[], baseType: string, @@ -1560,7 +1653,6 @@ export class DotContentDriveShellComponent implements OnDestroy { // action there is nothing to guard against here: each submission carries its own // freshly chosen files, so two uploads at once is legitimate rather than a double-fire. operation: `${UPLOAD_BATCH_OPERATION}:${(this.#uploadSequence += 1)}`, - actionName: this.#dotMessageService.get('content-drive.upload'), // Its own wording rather than the workflow sentence. Without this the run reads // "Applying Upload to demo.dotcms.com" — a phrasing for an action applied TO content, // which is not what putting files INTO a place is. @@ -1570,7 +1662,6 @@ export class DotContentDriveShellComponent implements OnDestroy { // "file(s)", which is what the count is for. labelKey: uploadIndicatorKey(files.length), total: files.length, - targetLabel: this.uploadTargetLabel(hostFolder), // Empty on purpose. The indicator speaks only for runs with nothing to mark, since a // run over rows is already reported by those rows dimming. An upload's content does not // exist until the run creates it, so the indicator is its only surface — naming the @@ -1651,10 +1742,8 @@ export class DotContentDriveShellComponent implements OnDestroy { const backgroundRunId = this.#store.startExternalRun({ operation: `${UPLOAD_BATCH_OPERATION}:${event.handle.jobId}`, - actionName: this.#dotMessageService.get('content-drive.upload'), labelKey: uploadIndicatorKey(submitted, { backgrounded: true }), total: submitted, - targetLabel: this.uploadTargetLabel(hostFolder), targets: [] }); @@ -1796,7 +1885,7 @@ export class DotContentDriveShellComponent implements OnDestroy { return; } - const { folderName, pathToMove, dragItems } = this.getMoveMetadata(event); + const { pathToMove, dragItems } = this.getMoveMetadata(event); const dragItemsInodes = dragItems.contentlets.map((item) => item.inode); const assetContentletsCount = dragItems.contentlets.length; @@ -1806,9 +1895,7 @@ export class DotContentDriveShellComponent implements OnDestroy { // which the indicator says better and without stacking up over the outcome that follows. const runId = this.#store.startExternalRun({ operation: MOVE_TO_FOLDER_WORKFLOW_ACTION_ID, - actionName: this.#dotMessageService.get('content-drive.context-menu.move'), total: assetContentletsCount, - targetLabel: folderName, targets: dragItemsInodes }); diff --git a/core-web/libs/portlets/dot-content-drive/portlet/src/lib/shared/models.ts b/core-web/libs/portlets/dot-content-drive/portlet/src/lib/shared/models.ts index e1dc8fe3ed9f..1c4c5b43fd9d 100644 --- a/core-web/libs/portlets/dot-content-drive/portlet/src/lib/shared/models.ts +++ b/core-web/libs/portlets/dot-content-drive/portlet/src/lib/shared/models.ts @@ -142,18 +142,8 @@ export interface DotContentDriveDialog { * progress instead of offering to fire it again. */ export interface DotContentDriveActionExecution { - /** Already-resolved action label, not an i18n key — it goes straight into the indicator. */ - actionName: string; /** Number of contentlets the run was fired over. */ total: number; - /** - * What the run is being applied to, when that is one nameable thing. - * - * Lets a single-item run read "Applying **Publish** to *My Page*" instead of "to 1 item(s)". - * Like `actionName` it reaches the indicator's `[innerHTML]`, and unlike `actionName` it is - * content the author typed, so escaping it is not optional. - */ - targetLabel?: string; /** * Copy this run names itself with, instead of the indicator's "Applying X to Y" form. * @@ -163,15 +153,6 @@ export interface DotContentDriveActionExecution { * operation. Resolved with the target label and the total as arguments, in that order. */ labelKey?: string; - /** - * How many items are done, when the run reports it. - * - * **Optional on purpose.** Absent means the run does not report progress, which the indicator - * shows as activity without a position — never as zero. Progress readback is the backend's - * largest piece of hidden work, so the indicator must degrade to indeterminate rather than - * assume a number exists. - */ - processed?: number; } /** @@ -464,3 +445,28 @@ export type DotContentDriveFilters = Partial & { * @interface DotContentDriveDecodeFunction */ export type DotContentDriveDecodeFunction = (value: string) => string | string[]; + +/** A single workflow filter entry: one scheme, optionally pinned to a step. */ +export interface WorkflowFilterEntry { + scheme: string; + step?: string; +} + +/** + * One level of the folder hierarchy returned by {@link getFolderHierarchyByPath}. + * `path` is the parent path that was queried; `folders` are its direct children (first page). + */ +export type FolderTreeHierarchyLevel = { + path: string; + folders: DotFolder[]; + totalEntries: number; + /** + * The 1-based page "Load more" should request next for this level, expressed in + * {@link FOLDER_TREE_PAGE_SIZE} units because that is what load-more pages by. + * + * Derived from the folders actually fetched, never from the rendered node count: a level can + * carry one extra folder that {@link resolveHierarchyAncestor} appended out of sort order, and + * counting that as paged-through would make load-more skip a page of real folders. + */ + nextPage: number; +}; diff --git a/core-web/libs/portlets/dot-content-drive/portlet/src/lib/store/dot-content-drive.store.spec.ts b/core-web/libs/portlets/dot-content-drive/portlet/src/lib/store/dot-content-drive.store.spec.ts index a1bd44329979..cb7d0b66a8ab 100644 --- a/core-web/libs/portlets/dot-content-drive/portlet/src/lib/store/dot-content-drive.store.spec.ts +++ b/core-web/libs/portlets/dot-content-drive/portlet/src/lib/store/dot-content-drive.store.spec.ts @@ -2109,7 +2109,7 @@ describe('DotContentDriveStore - withActionExecution', () => { store.executeQuickAction('LOCK', 'Lock', ['inode-2']); const lockInFlight = store.actionExecution(); - expect(lockInFlight).toEqual(expect.objectContaining({ actionName: 'Lock', total: 1 })); + expect(lockInFlight).toEqual(expect.objectContaining({ operation: 'LOCK', total: 1 })); store.reportRefreshCompleted('Refresh', { jobId: 'job-1', @@ -2170,7 +2170,7 @@ describe('DotContentDriveStore - withActionExecution', () => { store.executeQuickAction('LOCK', 'Lock', ['inode-2']); const lockInFlight = store.actionExecution(); - expect(lockInFlight).toEqual(expect.objectContaining({ actionName: 'Lock', total: 1 })); + expect(lockInFlight).toEqual(expect.objectContaining({ operation: 'LOCK', total: 1 })); store.reportRefreshCompleted('Refresh', { jobId: 'job-1', @@ -2290,7 +2290,7 @@ describe('DotContentDriveStore - withActionExecution', () => { store.executeQuickAction('LOCK', 'Lock', ['inode-1', 'inode-2']); expect(store.actionExecution()).toEqual( - expect.objectContaining({ actionName: 'Lock', total: 2 }) + expect.objectContaining({ operation: 'LOCK', total: 2 }) ); }); @@ -2576,7 +2576,7 @@ describe('DotContentDriveStore - withActionExecution', () => { store.executeAddToBundle('Add to Bundle', BUNDLE, ['id-1', 'id-2']); expect(store.actionExecution()).toEqual( - expect.objectContaining({ actionName: 'Add to Bundle', total: 2 }) + expect.objectContaining({ operation: 'Add to Bundle', total: 2 }) ); }); @@ -2750,7 +2750,7 @@ describe('DotContentDriveStore - withActionExecution', () => { store.executePushPublish('Push Publish', ['id-1', 'id-2'], SETTINGS); expect(store.actionExecution()).toEqual( - expect.objectContaining({ actionName: 'Push Publish', total: 2 }) + expect.objectContaining({ operation: 'Push Publish', total: 2 }) ); }); diff --git a/core-web/libs/portlets/dot-content-drive/portlet/src/lib/store/features/action-execution/withActionExecution.spec.ts b/core-web/libs/portlets/dot-content-drive/portlet/src/lib/store/features/action-execution/withActionExecution.spec.ts index 6e287ae357db..34aed983d694 100644 --- a/core-web/libs/portlets/dot-content-drive/portlet/src/lib/store/features/action-execution/withActionExecution.spec.ts +++ b/core-web/libs/portlets/dot-content-drive/portlet/src/lib/store/features/action-execution/withActionExecution.spec.ts @@ -142,7 +142,7 @@ describe('withActionExecution', () => { // `objectContaining`: a run now also carries its id, operation and targets. The two // fields the indicator reads are what this pins. expect(store.actionExecution()).toEqual( - expect.objectContaining({ actionName: 'Lock', total: 2 }) + expect.objectContaining({ operation: 'lock-id', total: 2 }) ); }); @@ -181,7 +181,7 @@ describe('withActionExecution', () => { expect(fireDefaultAction).toHaveBeenCalledTimes(1); expect(store.actionExecution()).toEqual( - expect.objectContaining({ actionName: 'Lock', total: 1 }) + expect.objectContaining({ operation: 'lock-id', total: 1 }) ); }); @@ -626,7 +626,6 @@ describe('withActionExecution', () => { build(); const runId = store.startExternalRun({ operation: 'upload:1', - actionName: 'Upload', total: 1, targets: [] as string[] }); @@ -728,7 +727,7 @@ describe('withActionExecution', () => { // Was a real hazard when there was one slot to wipe. Keying runs by id removes it by // construction, so this now guards the property rather than the workaround. expect(store.actionExecution()).toEqual( - expect.objectContaining({ actionName: 'Lock', total: 1 }) + expect.objectContaining({ operation: 'lock-id', total: 1 }) ); }); diff --git a/core-web/libs/portlets/dot-content-drive/portlet/src/lib/store/features/action-execution/withActionExecution.ts b/core-web/libs/portlets/dot-content-drive/portlet/src/lib/store/features/action-execution/withActionExecution.ts index 14c54e131e24..ad40092ccc71 100644 --- a/core-web/libs/portlets/dot-content-drive/portlet/src/lib/store/features/action-execution/withActionExecution.ts +++ b/core-web/libs/portlets/dot-content-drive/portlet/src/lib/store/features/action-execution/withActionExecution.ts @@ -329,7 +329,6 @@ export function withActionExecution() { const runId = startRun({ operation: actionName, - actionName, // Counted in identifiers, because that is what the server queues: language // versions of one contentlet are one asset. total: identifiers.length, @@ -408,7 +407,6 @@ export function withActionExecution() { const runId = startRun({ operation: actionId, - actionName, total: inodes.length, targets: inodes }); @@ -629,7 +627,6 @@ export function withActionExecution() { const runId = startRun({ operation: workflowActionId, - actionName, total: contentletIds.length, targets: contentletIds }); @@ -752,28 +749,6 @@ export function withActionExecution() { /** Settles a run registered with {@link startExternalRun}. */ endExternalRun: (runId: string): void => endRun(runId), - /** - * Updates a run in flight, for the fields it reports as it goes. - * - * Ignores a run that is already gone rather than resurrecting it: progress can - * arrive a tick after the run settled, and re-adding it would leave the - * indicator reporting something finished. - */ - updateExternalRun: ( - runId: string, - patch: Partial> - ): void => { - const run = store.runs()[runId]; - - if (!run) { - return; - } - - patchState(store, { - runs: { ...store.runs(), [runId]: { ...run, ...patch } } - }); - }, - /** * Remembers a batch this store submitted, so its completion can be told from * another tab's. diff --git a/core-web/libs/portlets/dot-content-drive/portlet/src/lib/store/features/sidebar/withSidebar.ts b/core-web/libs/portlets/dot-content-drive/portlet/src/lib/store/features/sidebar/withSidebar.ts index 9e71b1fa5e23..d58fa0bea279 100644 --- a/core-web/libs/portlets/dot-content-drive/portlet/src/lib/store/features/sidebar/withSidebar.ts +++ b/core-web/libs/portlets/dot-content-drive/portlet/src/lib/store/features/sidebar/withSidebar.ts @@ -23,10 +23,9 @@ import { SYSTEM_HOST, SYSTEM_HOST_PATH } from '../../../shared/constants'; -import { DotContentDriveState } from '../../../shared/models'; +import { DotContentDriveState, FolderTreeHierarchyLevel } from '../../../shared/models'; import { applyLoadMoreToHierarchy, - FolderTreeHierarchyLevel, getFolderHierarchyByPath, getFolderNodesByPath } from '../../../utils/functions'; @@ -216,19 +215,6 @@ export function withSidebar() { }); }, - /** - * Selects the tree's root row, the one that stands for the site rather than a folder. - * - * Used when a search spans the whole site, where no single folder is the selected one. - * A tree of plain folders has no such row, and then nothing is selected, which says the - * same thing. - */ - selectRootNode: () => { - patchState(store, { - selectedNode: store.folders().find((folder) => !folder.data?.path) - }); - }, - /** * Updates the folders array. * Uses structuredClone to create a deep copy of the folders array. diff --git a/core-web/libs/portlets/dot-content-drive/portlet/src/lib/utils/functions.ts b/core-web/libs/portlets/dot-content-drive/portlet/src/lib/utils/functions.ts index ebe28c45f28d..d22612255703 100644 --- a/core-web/libs/portlets/dot-content-drive/portlet/src/lib/utils/functions.ts +++ b/core-web/libs/portlets/dot-content-drive/portlet/src/lib/utils/functions.ts @@ -33,7 +33,9 @@ import { import { DotContentDriveDecodeFunction, DotContentDriveFilters, - DotKnownContentDriveFilters + DotKnownContentDriveFilters, + FolderTreeHierarchyLevel, + WorkflowFilterEntry } from '../shared/models'; /** @@ -59,12 +61,6 @@ const multiSelector = (value = ''): string[] => */ const singleSelector: DotContentDriveDecodeFunction = (value = ''): string => value.trim(); -/** A single workflow filter entry: one scheme, optionally pinned to a step. */ -export interface WorkflowFilterEntry { - scheme: string; - step?: string; -} - /** Separator for the `schemeId[:stepId]` workflow token encoding. */ export const WORKFLOW_TOKEN_SEPARATOR = ':'; @@ -438,25 +434,6 @@ export function folderSearchViewToDotFolder(view: FolderSearchView, hostName: st }; } -/** - * One level of the folder hierarchy returned by {@link getFolderHierarchyByPath}. - * `path` is the parent path that was queried; `folders` are its direct children (first page). - */ -export type FolderTreeHierarchyLevel = { - path: string; - folders: DotFolder[]; - totalEntries: number; - /** - * The 1-based page "Load more" should request next for this level, expressed in - * {@link FOLDER_TREE_PAGE_SIZE} units because that is what load-more pages by. - * - * Derived from the folders actually fetched, never from the rendered node count: a level can - * carry one extra folder that {@link resolveHierarchyAncestor} appended out of sort order, and - * counting that as paged-through would make load-more skip a page of real folders. - */ - nextPage: number; -}; - /** * The last segment of a folder path: `/a/b/` → `b`, `/a/` → `a`. * @@ -978,3 +955,23 @@ export const browsedFolderRef = ( path === SYSTEM_HOST_PATH ? toFolderRef(SYSTEM_HOST.hostname, ROOT_PATH) : toFolderRef(hostname, path); + +/** + * Which wording an upload run names itself with. + * + * The messages spell "file" or "files" out instead of hedging with "file(s)", so the count has to + * choose between them, and only the caller knows the count. Nothing here names a destination: the + * drive already shows where the author is, and the sentence was long enough that the part they + * could read at a glance was getting lost behind the part they could not. + * + * @param {number} count - How many files the batch carries + * @param {{ backgrounded?: boolean }} [options] - Whether the batch is the server's now + * @returns {string} the message key for that run + */ +export const uploadIndicatorKey = (count: number, options?: { backgrounded?: boolean }): string => { + const base = options?.backgrounded + ? 'content-drive.upload.indicator.background' + : 'content-drive.upload.indicator'; + + return count === 1 ? `${base}.one` : base; +}; diff --git a/dotCMS/src/main/webapp/WEB-INF/messages/Language.properties b/dotCMS/src/main/webapp/WEB-INF/messages/Language.properties index 80f1b0228e4c..7347ce615fe7 100644 --- a/dotCMS/src/main/webapp/WEB-INF/messages/Language.properties +++ b/dotCMS/src/main/webapp/WEB-INF/messages/Language.properties @@ -7370,10 +7370,10 @@ content-drive.upload.toast.already-uploaded-again=This batch was uploaded before # in-flight fact this flow announces, because it is a rule changing rather than work happening. # The indicator's words for the server phase. "Applying X to Y" would describe an operation the # author has to wait for, which is the opposite of what they were just told. -content-drive.upload.indicator=Uploading {1} files… -content-drive.upload.indicator.one=Uploading {1} file… -content-drive.upload.indicator.background=Uploading {1} files in the background… -content-drive.upload.indicator.background.one=Uploading {1} file in the background… +content-drive.upload.indicator=Uploading {0} files… +content-drive.upload.indicator.one=Uploading {0} file… +content-drive.upload.indicator.background=Uploading {0} files in the background… +content-drive.upload.indicator.background.one=Uploading {0} file in the background… # Refused submissions. The endpoint keeps its two ceilings distinguishable by status, so these # stay two sentences: "fewer files" and "smaller files" are different instructions. content-drive.upload.refused.too-large=That batch is over the upload size limit. Nothing was uploaded, so try again with fewer or smaller files. @@ -7421,6 +7421,9 @@ content-drive.toast.download-success-detail=The download has started. content-drive.chip-filter.overflow-label={0} and {1} more content-drive.filters.clear-all=Clear all content-drive.shared-assets-filter.title=Show System Host +content-drive.scope-bar.all-site-content.excluded=All Files in site (System Host shared files excluded) +content-drive.scope-bar.all-site-content.included=All Files in site (System Host shared files included) +content-drive.scope-bar.include-system-host=Include System Host: content-drive.sidebar.all-site-content=All Site Content content-drive.sidebar.system-host=System Host diff --git a/dotcms-integration/src/test/java/com/dotcms/rest/api/v1/drive/ContentDriveBrowseScopeTest.java b/dotcms-integration/src/test/java/com/dotcms/rest/api/v1/drive/ContentDriveBrowseScopeTest.java index 472843fe3b39..86a9b4b56a1e 100644 --- a/dotcms-integration/src/test/java/com/dotcms/rest/api/v1/drive/ContentDriveBrowseScopeTest.java +++ b/dotcms-integration/src/test/java/com/dotcms/rest/api/v1/drive/ContentDriveBrowseScopeTest.java @@ -11,6 +11,7 @@ import com.dotcms.datagen.FolderDataGen; import com.dotcms.datagen.SiteDataGen; import com.dotcms.util.IntegrationTestInitService; +import com.dotcms.rest.exception.BadRequestException; import com.dotmarketing.beans.Host; import com.dotmarketing.business.APILocator; import com.dotmarketing.exception.DotDataException; @@ -23,7 +24,6 @@ import java.util.function.BiConsumer; import java.util.stream.Collectors; import javax.enterprise.context.ApplicationScoped; -import javax.ws.rs.BadRequestException; import org.junit.AfterClass; import org.junit.BeforeClass; import org.junit.Test; @@ -380,6 +380,11 @@ public void testNoScopeAtTheSiteRootMatchesAllSiteContent() */ @Test public void testAScopeWithAFolderPathIsRefused() { + // `com.dotcms.rest.exception.BadRequestException`, not the JAX-RS class of the same name. + // They are siblings rather than subtypes -- this one extends `HttpStatusCodeException` -- + // so naming the wrong one compiles and then fails the moment the test first runs. The + // unit-level twin of this assertion deliberately catches `RuntimeException` and checks the + // message, which is why it never noticed. assertThrows("A scope is only meaningful at the site root", BadRequestException.class, () -> requestAt(childFolderPath).browseScope(BrowseScope.ROOT).build()); From 35ede6ca6cafb84799987b4296df58a39aa329ba Mon Sep 17 00:00:00 2001 From: Jalinson Diaz Date: Thu, 17 Sep 2026 08:47:49 -0300 Subject: [PATCH 40/44] fix(content-drive): stop the status toast swallowing clicks on the paginator (#37426) The toast reported a run from directly on top of the page controls and ate the click. The page never changed, so the listing sat on one page while the paginator read as though it were on another. The outlet is a fixed box at the bottom centre of the viewport, which is exactly where the listing's paginator is, and nothing -- not PrimeNG, not this stylesheet -- was setting `pointer-events`. So the container took every click that landed on it, including the ones meant for what was underneath. Nothing in this toast is clickable, so nothing in it may take a click. Blanking the whole subtree is safe rather than re-enabling it on the message: the outlet has no controls at all since the close button was removed, because a status is not something the reader acts on. It also drops hover-to-pause, which this toast does not want either -- it is sticky and ends when the run it reports ends. Scoped to this outlet. `dot-toast` at the top still has its close button and still needs its clicks. The choice of `bottom-center` is what put it there, and what lives at the bottom of this portlet was not checked when that was picked. Tested in e2e, because catching this needs real hit-testing: jsdom has none, and a unit test could only assert the CSS property rather than the behaviour. Playwright fails a click an overlay intercepts and names the element in the way, so the click is the assertion -- upload a file, wait for the toast, then click rows-per-page. That control rather than next-page: it is always enabled, whatever the folder holds. Co-Authored-By: Claude Opus 5 (1M context) --- .../src/pages/contentDrive.page.ts | 14 +++++++++ .../content-drive-browse-scopes.spec.ts | 31 +++++++++++++++++++ .../dot-status-toast.component.scss | 16 ++++++++++ 3 files changed, 61 insertions(+) diff --git a/core-web/apps/dotcms-ui-e2e/src/pages/contentDrive.page.ts b/core-web/apps/dotcms-ui-e2e/src/pages/contentDrive.page.ts index 47d5e4ded769..20d49938b06f 100644 --- a/core-web/apps/dotcms-ui-e2e/src/pages/contentDrive.page.ts +++ b/core-web/apps/dotcms-ui-e2e/src/pages/contentDrive.page.ts @@ -336,6 +336,20 @@ export class ContentDrivePage { await expect(this.statusToastSummary).toHaveCount(0, { timeout: OUTCOME_TIMEOUT }); } + /** + * Clicks the listing's rows-per-page control. + * + * Used to prove the status toast is not sitting on top of it. This control is at the bottom + * centre of the viewport, which is exactly where the toast renders, and it is always present + * and enabled -- unlike the next-page button, which depends on how much the folder holds. + * + * Playwright fails a click that an overlay intercepts, naming the element in the way, so the + * assertion is the click itself. + */ + async openRowsPerPage() { + await this.page.getByRole('combobox', { name: 'Rows per page' }).click({ timeout: 5000 }); + } + /** Whether the scope bar is open, which is a question about height rather than presence. */ async scopeBarIsOpen(): Promise { const slot = this.page.getByTestId('scope-bar-slot'); diff --git a/core-web/apps/dotcms-ui-e2e/src/tests/content-drive/content-drive-browse-scopes.spec.ts b/core-web/apps/dotcms-ui-e2e/src/tests/content-drive/content-drive-browse-scopes.spec.ts index 8efc23b88bd8..04cd56c65a42 100644 --- a/core-web/apps/dotcms-ui-e2e/src/tests/content-drive/content-drive-browse-scopes.spec.ts +++ b/core-web/apps/dotcms-ui-e2e/src/tests/content-drive/content-drive-browse-scopes.spec.ts @@ -247,6 +247,37 @@ test.describe('Content Drive Browse Scopes', () => { } }); + test('leaves the page controls clickable while a run is reported @critical', async ({ + adminPage, + apiHelpers, + testSuffix + }) => { + // The status toast is a fixed box at the bottom centre of the viewport, which is where the + // paginator lives. It reported an upload from directly on top of the page controls and + // swallowed the click, so the page never changed and the listing sat on one page while the + // paginator read as another. + // + // Nothing in the toast is clickable, so nothing in it should take a click. Playwright fails + // a click an overlay intercepts and names the element in the way, which makes the click + // itself the assertion. + const site = await apiHelpers.getDefaultSite(); + const folderName = `cd-click-${testSuffix}`; + await apiHelpers.createFolders(site.hostname, [`/${folderName}`]); + + const drive = new ContentDrivePage(adminPage); + + try { + await drive.goTo(); + await drive.openFolder(folderName); + await drive.chooseFilesForUpload([`click-${testSuffix}.png`]); + await drive.expectStatusToastContaining('Uploading'); + + await drive.openRowsPerPage(); + } finally { + await apiHelpers.deleteFolders(site.hostname, [`/${folderName}`]); + } + }); + test('says what all site content is showing, and only there @critical', async ({ adminPage }) => { diff --git a/core-web/libs/ui/src/lib/components/dot-status-toast/dot-status-toast.component.scss b/core-web/libs/ui/src/lib/components/dot-status-toast/dot-status-toast.component.scss index 52cbe0eb4e0c..f9d152fbb28b 100644 --- a/core-web/libs/ui/src/lib/components/dot-status-toast/dot-status-toast.component.scss +++ b/core-web/libs/ui/src/lib/components/dot-status-toast/dot-status-toast.component.scss @@ -6,6 +6,22 @@ // variables, so a fallback never applies and the rule silently has no effect. That is why the // summary kept rendering at the theme's 14px while this file asked for something smaller. ::ng-deep { + // Nothing here is clickable, so nothing here may take a click. + // + // The outlet is a fixed-position box at the bottom centre of the viewport, which is exactly + // where the listing's paginator is. Without this it sits over the page controls and + // swallows the click: the page never changes, and the listing is left showing one page + // while the paginator believes it is on another. + // + // Safe to blank the whole subtree rather than re-enabling it on the message, because this + // outlet has no controls at all -- the close button was removed, and a status is not + // something the reader acts on. It also means no hover-to-pause, which this toast does not + // want either: it is sticky and ends when the run it reports ends. + .p-toast, + .p-toast-message { + pointer-events: none; + } + // Sized to its content, not to a fixed 350px — a two-word outcome should not sit in a slab. // It grows with the label instead of clipping it, up to a ceiling where it wraps, because // what a run has to say is not the same length every time. From 9e30f745a4861db5173251b5f1b8657dc4a1df3a Mon Sep 17 00:00:00 2001 From: Jalinson Diaz Date: Thu, 17 Sep 2026 10:33:43 -0300 Subject: [PATCH 41/44] fix(content-drive): repair the e2e this branch added, and amend the spec to match (#37426) **The e2e.** Five tests were added without ever being run; three failed on their first CI pass, and both causes were mine. Two of them clicked All Site Content immediately after landing, which is the view the drive already opens on. Clicking the entry you are already on changes no location, so the store re-requests nothing and the helper's `waitForResponse` never resolves; the test then died on its own timeout reporting "Page closed", which says nothing about what it was testing. Confirmed against a live instance: zero `/v1/drive/search` requests fire from that click. `selectSidebarEntry` now returns early when the entry is already current. The third clicked the listing's rows-per-page control to prove the status toast was not sitting on top of it, on the claim that the control is "always enabled". It is not: an empty listing disables it, and a test that seeds its own folder always starts empty, so the check failed on the control rather than on the toast. It now asks the browser's hit-testing directly -- `elementFromPoint` at the toast's own centre -- which tests the actual claim and needs nothing enabled underneath. **The spec**, per the convergence findings and the developer's decisions on each: FR-013, US1/AC6 and SC-008 no longer require the in-flight status to name the site. That requirement was written before the copy was shortened on 2026-09-16; FR-013 now records why the destination went and what it costs, which is that an upload in the System Host scope says how many files are going but not where, while the site switcher above still names a different site. FR-017 and FR-018 describe the scope bar instead of the filter-row chip: where it lives, that its sentence must agree with its toggle, that it appears only under All Site Content, that it opens by height, and that it must not show an item count -- the endpoint pages by cursor and returns no total, so any number there would be invented. The Legacy Considerations note is corrected too: the shared "Show Shared Assets" rename now lands on the Asset Picker alone, because Content Drive no longer uses that label. Verified on the new environment image rather than reasoned about: the bar renders and reads correctly, the toggle is 28x16 against a 3rem base (the token override works where `size="small"` did nothing), and the bar closes 31px to 19px to 0 rather than snapping. Closes T069, T070, T071, T072. T073 -- re-approval -- stands. Co-Authored-By: Claude Opus 5 (1M context) --- .../src/pages/contentDrive.page.ts | 34 ++++++++++++++----- .../content-drive-browse-scopes.spec.ts | 8 ++--- .../37426-content-drive-browse-scopes/spec.md | 12 +++---- 3 files changed, 36 insertions(+), 18 deletions(-) diff --git a/core-web/apps/dotcms-ui-e2e/src/pages/contentDrive.page.ts b/core-web/apps/dotcms-ui-e2e/src/pages/contentDrive.page.ts index 20d49938b06f..84865c528d50 100644 --- a/core-web/apps/dotcms-ui-e2e/src/pages/contentDrive.page.ts +++ b/core-web/apps/dotcms-ui-e2e/src/pages/contentDrive.page.ts @@ -337,17 +337,27 @@ export class ContentDrivePage { } /** - * Clicks the listing's rows-per-page control. + * Whether the status toast is what a click would land on at its own centre. * - * Used to prove the status toast is not sitting on top of it. This control is at the bottom - * centre of the viewport, which is exactly where the toast renders, and it is always present - * and enabled -- unlike the next-page button, which depends on how much the folder holds. + * Asked of the browser's hit-testing rather than by clicking a control underneath. The first + * version of this clicked the listing's rows-per-page box on the claim that it is "always + * enabled"; it is not -- an empty folder disables it, and a test that seeds its own folder + * always starts empty, so the check failed on the control rather than on the toast. * - * Playwright fails a click that an overlay intercepts, naming the element in the way, so the - * assertion is the click itself. + * `elementFromPoint` asks the question directly and needs nothing beneath the toast at all. */ - async openRowsPerPage() { - await this.page.getByRole('combobox', { name: 'Rows per page' }).click({ timeout: 5000 }); + async statusToastTakesClicksAtItsCentre(): Promise { + const box = await this.statusToast.boundingBox(); + + if (!box) { + throw new Error('no status toast on screen to test'); + } + + return this.page.evaluate( + ([x, y]) => + !!document.elementFromPoint(x, y)?.closest('[data-testid="dot-status-toast"]'), + [box.x + box.width / 2, box.y + box.height / 2] + ); } /** Whether the scope bar is open, which is a question about height rather than presence. */ @@ -485,6 +495,14 @@ export class ContentDrivePage { * afterwards never resolves. */ private async selectSidebarEntry(row: Locator) { + // Clicking the entry you are already on changes no location, so the store re-requests + // nothing and a wait for the listing never resolves -- the test then dies on its own + // timeout with "Page closed", which says nothing about the entry. The drive lands on all + // site content, so this is the ordinary case for a test that starts there. + if ((await row.getAttribute('aria-current')) === 'true') { + return; + } + const listing = this.page.waitForResponse( (response) => response.url().includes('/v1/drive/search') && response.ok() ); diff --git a/core-web/apps/dotcms-ui-e2e/src/tests/content-drive/content-drive-browse-scopes.spec.ts b/core-web/apps/dotcms-ui-e2e/src/tests/content-drive/content-drive-browse-scopes.spec.ts index 04cd56c65a42..5964dca9be13 100644 --- a/core-web/apps/dotcms-ui-e2e/src/tests/content-drive/content-drive-browse-scopes.spec.ts +++ b/core-web/apps/dotcms-ui-e2e/src/tests/content-drive/content-drive-browse-scopes.spec.ts @@ -257,9 +257,9 @@ test.describe('Content Drive Browse Scopes', () => { // swallowed the click, so the page never changed and the listing sat on one page while the // paginator read as another. // - // Nothing in the toast is clickable, so nothing in it should take a click. Playwright fails - // a click an overlay intercepts and names the element in the way, which makes the click - // itself the assertion. + // Nothing in the toast is clickable, so nothing in it should take a click. Asked of the + // browser's own hit-testing at the toast's centre, which needs nothing enabled underneath + // -- a freshly seeded folder is empty, and an empty listing disables its page controls. const site = await apiHelpers.getDefaultSite(); const folderName = `cd-click-${testSuffix}`; await apiHelpers.createFolders(site.hostname, [`/${folderName}`]); @@ -272,7 +272,7 @@ test.describe('Content Drive Browse Scopes', () => { await drive.chooseFilesForUpload([`click-${testSuffix}.png`]); await drive.expectStatusToastContaining('Uploading'); - await drive.openRowsPerPage(); + expect(await drive.statusToastTakesClicksAtItsCentre()).toBe(false); } finally { await apiHelpers.deleteFolders(site.hostname, [`/${folderName}`]); } diff --git a/specs/37426-content-drive-browse-scopes/spec.md b/specs/37426-content-drive-browse-scopes/spec.md index defd6d46efc9..21ac45bc2a1b 100644 --- a/specs/37426-content-drive-browse-scopes/spec.md +++ b/specs/37426-content-drive-browse-scopes/spec.md @@ -29,7 +29,7 @@ An editor opens Content Drive and wants to see what actually lives at the top of 3. **Given** the user has selected **All Site Content**, **When** they select a folder in the hierarchy, **Then** the listing shows that folder's contents exactly as it does today. 4. **Given** the user has selected the site row, **When** they look at the listing, **Then** no System Host content appears in it regardless of any other setting. 5. **Given** any of the three sidebar entries is selected, **When** the user selects a different one, **Then** the previous selection is cleared, so exactly one entry is ever active. -6. **Given** the user has selected **All Site Content**, **When** they add content by any route — uploading, creating, or dropping files onto the listing — **Then** it is accepted and lands at the site root, and the progress indicator names the site so the author can see where it went. +6. **Given** the user has selected **All Site Content**, **When** they add content by any route — uploading, creating, or dropping files onto the listing — **Then** it is accepted and lands at the site root. 7. **Given** the user is on a later page of **All Site Content**, **When** they select a different sidebar entry, **Then** the listing starts again at its first page with no items still selected. 8. **Given** the site row is selected and a search is running, **When** the search is served by either of the product's two internal search paths, **Then** both return the same items, and neither admits content from inside a folder or from System Host. @@ -107,7 +107,7 @@ Having selected some content, a user drags it onto the **System Host** entry to #### Creating, uploading and moving -- **FR-013**: **All Site Content** MUST accept new content, which lands at the **site root**. Uploading, creating and dropping files onto the listing all behave as they do on the site row, and the progress indicator MUST name the site so the destination is visible rather than inferred. +- **FR-013**: **All Site Content** MUST accept new content, which lands at the **site root**. Uploading, creating and dropping files onto the listing all behave as they do on the site row. The in-flight status deliberately does NOT name the destination: it was tried and removed on 2026-09-16 because the sentence had grown long enough that the part an author reads at a glance was lost behind the part they do not. The cost is knowingly accepted — in the System Host scope the site switcher still names a different site, so an upload there says only how many files are going, not where. This reverses an earlier version of this requirement, which made the view read-only on the grounds that a view spanning the whole site "names no single place to put anything". That is true of the view and false of the product: the site root is the obvious destination, and **this is the content search view, where uploading has always been possible and has always landed on the current site**. Refusing the upload prevented no mistake — it sent people to the site row to do the same thing one click later. @@ -118,8 +118,8 @@ Having selected some content, a user drags it onto the **System Host** entry to #### The System Host toggle -- **FR-017**: The existing "Show Shared Assets" control MUST be renamed **"Show System Host"**. Only the label changes; links already in circulation that carry the control's current value MUST keep restoring correctly. -- **FR-018**: The control MUST be offered only while **All Site Content** is selected, and MUST animate in and out the way the filter row's other conditional chips do, collapsing its width rather than appearing and vanishing. A control with nothing to decide should not be sitting there, and the animation is what stops the chips beside it jumping under the pointer when the selection changes. +- **FR-017**: The control that decides whether System Host content appears MUST live in a bar above the listing rather than among the filter chips, beside a sentence naming what the listing is currently showing. The sentence MUST agree with the control: with System Host included it reads "All Files in site (System Host shared files included)", and with it excluded, "…excluded". Links already in circulation that carry the control's value MUST keep restoring correctly. The Asset Picker keeps the chip it has always had, labelled "Show System Host"; this requirement governs Content Drive only. +- **FR-018**: The bar MUST be offered only while **All Site Content** is selected — the site root and System Host each answer the question it asks simply by being chosen, leaving its sentence nothing to qualify and its toggle nothing to decide. It MUST open and close by height rather than appearing and vanishing, so the listing is pushed down instead of jumping under the pointer. It MUST NOT show an item count: the listing endpoint pages by cursor and returns no total, so any number shown there would be invented. - **FR-019**: The control MUST retain its value while another browse scope is selected, so returning to **All Site Content** restores the user's previous choice rather than resetting it. - **FR-020**: With the control on, **All Site Content** MUST include System Host content alongside the site's; with it off, **All Site Content** MUST exclude it. @@ -154,13 +154,13 @@ Having selected some content, a user drags it onto the **System Host** entry to - **SC-005**: Opening a shared Content Drive link reproduces the browse scope the sender was viewing, every time. - **SC-006**: Content is moved to System Host in a single drag, with no dialog and no intermediate step. - **SC-007**: Surfaces other than Content Drive that browse content list exactly what they listed before this change, a listing asked for without a browse scope returns exactly what it returns today, and every consumer of that listing is accounted for by name rather than covered by a blanket claim. -- **SC-008**: Content added from the All Site Content view — by any of uploading, creating, or dropping files onto the listing — arrives at the site root, and the author is told which site received it without having to change view to find out. +- **SC-008**: Content added from the All Site Content view — by any of uploading, creating, or dropping files onto the listing — arrives at the site root. ## Legacy Considerations *(dotCMS-specific — mandatory)* - **Existing behavior touched**: The Content Drive listing and the underlying content-browsing service it shares with the Asset Picker, which is long-standing code that predates Content Drive. The meaning of "the site is selected" changes deliberately: it becomes the site root rather than the whole site, with the whole-site view moving to its own **All Site Content** entry. That is a visible behavior change for existing Content Drive users and is the point of the feature. - **Backward-compatibility expectations**: **Preserving existing behavior is a hard constraint, not a preference.** The content-listing service Content Drive uses is shared with the assets API, the older file browser and its deprecated tree endpoint, the legacy admin browser, a Velocity viewtool, and two internal callers. None of them will ask for a scope, so whatever a scope-less request means today it must mean afterwards, byte for byte, including how System Host content is treated. Shared Content Drive links created before this change must keep restoring, including the value of the toggle being renamed. No deprecation of existing admin workflows is intended. -- **A rename that lands on two surfaces**: the "Show Shared Assets" label is a single shared translation used by both the Content Drive toolbar and the Asset Picker toolbar. Renaming it to "Show System Host" renames the chip in the Asset Picker as well. That is consistent terminology rather than a regression, but it is a visible change on a surface this feature does not otherwise touch, and it should be an accepted decision rather than a surprise. +- **A rename that now lands on one surface**: the "Show Shared Assets" label is a single shared translation that once served both the Content Drive toolbar and the Asset Picker toolbar. Content Drive no longer uses it — its control moved into the scope bar with wording of its own (FR-017) — so renaming it to "Show System Host" changes the Asset Picker alone. That is a visible change on a surface this feature does not otherwise touch, and it should be called out in review rather than discovered there. - **Known related decisions**: The listing has two internal query paths that can disagree about whether System Host content is included; they must agree before any browse scope can be trusted, which is why the spec requires a scope to return the same items with or without a search rather than leaving it as an implementation concern. Only one of the two runs by default, and the other is reachable only by configuration, so proving they agree means deliberately exercising the site-root and System Host scopes under each rather than waiting for the non-default one to show up on its own. Moving content and browsing content also address System Host by different means, so support for one does not imply support for the other. Issue #37166 is related: it touches how Content Drive reports operations and surfaced this while examining what a move actually changes in the listing. The plan phase will formally consult `dotCMS/platform-adrs`. ## Assumptions From f9a87cc29bcb0f50308adfc1f8474f4efcf8fc13 Mon Sep 17 00:00:00 2001 From: Jalinson Diaz Date: Thu, 17 Sep 2026 11:35:14 -0300 Subject: [PATCH 42/44] feat(content-drive): keep System Host out of reach of users who cannot read it (#37426) The entry was offered to everyone, and a `SYSTEM_HOST` location was honoured from any link. A user without access clicked it and got an empty listing -- which reads as "there is no shared content" rather than "this is not yours", and is the worse of the two answers. No new endpoint and no second request. `GET /api/v1/permissions/{assetId}` returns four booleans and none of them is a read answer, but the resource checks PERMISSION_READ before answering anything and refuses outright without it (`PermissionResource:688`) -- so a refusal from the call this store already makes IS the read answer. The old handler was swallowing it: every failure, refusals included, settled on allowed. Only a refusal denies. Any other failure leaves the scope available, because a timeout says nothing about permissions and locking someone out of a scope they hold is worse than offering an entry that lists nothing. Gated in two places because they fail differently. The sidebar omits the entry -- absent rather than disabled, the same call this feature already made about the toggle, and consistent with a redirect that explains nothing either. The store holds the real gate: an effect returning a `SYSTEM_HOST` location to all site content, silently, because the user did nothing wrong and usually just followed somebody's link. It lives in the store's own hooks rather than `withSidebar`, which composes earlier and cannot see the answer. Worth being exact about what this is: an affordance, not a defence. The listing enforces read permissions server-side already (FR-011), so nothing here is what stops content leaking -- it removes a door that opens onto an empty room. Written up as FR-016a, so the re-approval T073 already calls for covers it rather than it arriving unannounced. **Not verified against a real restricted user.** The three unit tests drive the store directly and the browser check used an administrator, for whom the gate never closes. Recorded as T075. Co-Authored-By: Claude Opus 5 (1M context) --- .../dot-content-drive-sidebar.component.html | 44 ++++++++------- ...ot-content-drive-sidebar.component.spec.ts | 21 +++++++ .../dot-content-drive-sidebar.component.ts | 9 +++ .../dot-content-drive-shell.component.spec.ts | 8 +++ .../src/lib/store/dot-content-drive.store.ts | 53 ++++++++++++++---- .../withSitePermissions.spec.ts | 56 +++++++++++++++++++ .../site-permissions/withSitePermissions.ts | 30 +++++++++- .../37426-content-drive-browse-scopes/spec.md | 2 + 8 files changed, 191 insertions(+), 32 deletions(-) diff --git a/core-web/libs/portlets/dot-content-drive/portlet/src/lib/components/dot-content-drive-sidebar/dot-content-drive-sidebar.component.html b/core-web/libs/portlets/dot-content-drive/portlet/src/lib/components/dot-content-drive-sidebar/dot-content-drive-sidebar.component.html index d973585dd74c..a5897d648936 100644 --- a/core-web/libs/portlets/dot-content-drive/portlet/src/lib/components/dot-content-drive-sidebar/dot-content-drive-sidebar.component.html +++ b/core-web/libs/portlets/dot-content-drive/portlet/src/lib/components/dot-content-drive-sidebar/dot-content-drive-sidebar.component.html @@ -41,22 +41,28 @@ (moveItems)="moveItems.emit($event)" /> - - + +@if ($systemHostVisible()) { + + +} diff --git a/core-web/libs/portlets/dot-content-drive/portlet/src/lib/components/dot-content-drive-sidebar/dot-content-drive-sidebar.component.spec.ts b/core-web/libs/portlets/dot-content-drive/portlet/src/lib/components/dot-content-drive-sidebar/dot-content-drive-sidebar.component.spec.ts index 403b7602e8c4..8ed7fb7d86e2 100644 --- a/core-web/libs/portlets/dot-content-drive/portlet/src/lib/components/dot-content-drive-sidebar/dot-content-drive-sidebar.component.spec.ts +++ b/core-web/libs/portlets/dot-content-drive/portlet/src/lib/components/dot-content-drive-sidebar/dot-content-drive-sidebar.component.spec.ts @@ -112,6 +112,8 @@ describe('DotContentDriveSidebarComponent', () => { const systemHostSelected = signal(false); // Drives the System Host row's drop gate the way the store's own lookup would. const systemHostCanAddChildren = signal(true); + // And whether the row is offered at all. True unless the server refused the lookup outright. + const systemHostCanRead = signal(true); const createComponent = createComponentFactory({ component: DotContentDriveSidebarComponent, @@ -123,6 +125,7 @@ describe('DotContentDriveSidebarComponent', () => { mockProvider(DotContentDriveStore, { initContentDrive: vi.fn(), systemHostCanAddChildren: systemHostCanAddChildren, + systemHostCanRead: systemHostCanRead, currentSite: vi.fn().mockReturnValue(mockSiteDetails), isTreeExpanded: vi.fn().mockReturnValue(true), removeFilter: vi.fn(), @@ -209,6 +212,24 @@ describe('DotContentDriveSidebarComponent', () => { }); }); + describe('reading System Host', () => { + afterEach(() => systemHostCanRead.set(true)); + + it('should offer the entry to a user who may read System Host', () => { + expect(spectator.query(byTestId('system-host'))).toBeTruthy(); + }); + + it('should offer no entry at all to a user who may not', () => { + // Hidden rather than disabled: a control with nothing to decide should not be sitting + // there, which is the call this feature already made about the toggle. The URL is + // gated in the store, because hiding a button stops nobody who has a link. + systemHostCanRead.set(false); + spectator.detectChanges(); + + expect(spectator.query(byTestId('system-host'))).toBeNull(); + }); + }); + describe('drag and drop onto the sidebar entries', () => { const dragWith = (row: Element | null, files: File[]) => { // This environment neither populates `files` from `items.add` nor carries a diff --git a/core-web/libs/portlets/dot-content-drive/portlet/src/lib/components/dot-content-drive-sidebar/dot-content-drive-sidebar.component.ts b/core-web/libs/portlets/dot-content-drive/portlet/src/lib/components/dot-content-drive-sidebar/dot-content-drive-sidebar.component.ts index a6d7b427eda2..358cb4a05336 100644 --- a/core-web/libs/portlets/dot-content-drive/portlet/src/lib/components/dot-content-drive-sidebar/dot-content-drive-sidebar.component.ts +++ b/core-web/libs/portlets/dot-content-drive/portlet/src/lib/components/dot-content-drive-sidebar/dot-content-drive-sidebar.component.ts @@ -87,6 +87,15 @@ export class DotContentDriveSidebarComponent { /** Whether the user may add content to System Host; unknown reads as allowed. */ readonly $systemHostCanAddChildren = this.#store.systemHostCanAddChildren; + /** + * Whether to offer the System Host entry at all. + * + * A user who cannot read it gets no entry rather than a disabled one. The scope is still + * gated in the store for anyone arriving by URL — this only stops the drive advertising a + * door that opens onto nothing. + */ + readonly $systemHostVisible = this.#store.systemHostCanRead; + /** * Whether a drag is currently over the all-site-content entry. * diff --git a/core-web/libs/portlets/dot-content-drive/portlet/src/lib/dot-content-drive-shell/dot-content-drive-shell.component.spec.ts b/core-web/libs/portlets/dot-content-drive/portlet/src/lib/dot-content-drive-shell/dot-content-drive-shell.component.spec.ts index 7fd9c35e0422..ffcfa2f80f8c 100644 --- a/core-web/libs/portlets/dot-content-drive/portlet/src/lib/dot-content-drive-shell/dot-content-drive-shell.component.spec.ts +++ b/core-web/libs/portlets/dot-content-drive/portlet/src/lib/dot-content-drive-shell/dot-content-drive-shell.component.spec.ts @@ -108,6 +108,7 @@ const canAddChildrenSignal: WritableSignal = signal(true); // The site-level answer the drop guard falls back to at the root. Module scope for the same reason // as the one above: two store mocks in this file read it from describes with no shared `beforeEach`. const siteCanAddChildrenSignal: WritableSignal = signal(undefined); +const systemHostCanReadSignal = signal(true); describe('DotContentDriveShellComponent', () => { let spectator: Spectator; @@ -226,6 +227,7 @@ describe('DotContentDriveShellComponent', () => { beforeEach(() => { canAddChildrenSignal.set(true); siteCanAddChildrenSignal.set(undefined); + systemHostCanReadSignal.set(true); filtersSignal = signal({}); statusSignal = signal(DotContentDriveStatus.LOADING); dialogSignal = signal(undefined); @@ -252,6 +254,9 @@ describe('DotContentDriveShellComponent', () => { // their creation affordances on it. $canAddChildren: canAddChildrenSignal, siteCanAddChildren: siteCanAddChildrenSignal, + // The sidebar this shell renders reads it to decide whether to offer the + // System Host entry at all. + systemHostCanRead: systemHostCanReadSignal, currentSite: currentSiteMock, // Tree collapsed at start to render the toggle button on toolbar isTreeExpanded: vi.fn().mockReturnValue(false), @@ -4879,6 +4884,9 @@ describe('DotContentDriveShellComponent — editContent deep link', () => { // their creation affordances on it. $canAddChildren: canAddChildrenSignal, siteCanAddChildren: siteCanAddChildrenSignal, + // The sidebar this shell renders reads it to decide whether to offer the + // System Host entry at all. + systemHostCanRead: systemHostCanReadSignal, currentSite: vi.fn().mockReturnValue(MOCK_SITES[0]), isTreeExpanded: vi.fn().mockReturnValue(false), items: vi.fn().mockReturnValue(MOCK_ITEMS), diff --git a/core-web/libs/portlets/dot-content-drive/portlet/src/lib/store/dot-content-drive.store.ts b/core-web/libs/portlets/dot-content-drive/portlet/src/lib/store/dot-content-drive.store.ts index c2a58c512628..251627267903 100644 --- a/core-web/libs/portlets/dot-content-drive/portlet/src/lib/store/dot-content-drive.store.ts +++ b/core-web/libs/portlets/dot-content-drive/portlet/src/lib/store/dot-content-drive.store.ts @@ -794,14 +794,47 @@ export const DotContentDriveStore = signalStore( }) }) ), - withHooks((store) => ({ - onInit() { - // Fed the signal rather than called on each site change: `rxMethod` re-runs on every - // emission and `switchMap` drops the previous site's in-flight answer, so switching - // sites quickly can never settle the gate with the wrong site's result. - store.loadSitePermissions(store.currentSite); - // Once, not per site: System Host belongs to none of them. - store.loadSystemHostPermissions(); - } - })) + withHooks((store) => { + let systemHostGate: EffectRef | undefined; + + return { + onInit() { + // Fed the signal rather than called on each site change: `rxMethod` re-runs on every + // emission and `switchMap` drops the previous site's in-flight answer, so switching + // sites quickly can never settle the gate with the wrong site's result. + store.loadSitePermissions(store.currentSite); + // Once, not per site: System Host belongs to none of them. + store.loadSystemHostPermissions(); + + /** + * Sends a user who cannot read System Host back to all site content. + * + * The sidebar hides the entry, but hiding a button is not a gate: the location is + * carried in the URL, so a link, a reload or a typed address reaches the scope + * without ever touching the sidebar. + * + * Silently, and to all site content rather than an error: the user did nothing + * wrong — usually they followed a colleague's link — and the drive has somewhere + * sensible to put them. This is an affordance, not a defence; the listing enforces + * read permissions on its own, so nothing here is what stops content leaking. + * + * Lives in the store's own hooks rather than in `withSidebar`, which composes + * earlier and cannot see this answer. + */ + systemHostGate = effect(() => { + const onSystemHost = store.$systemHostSelected(); + const canRead = store.systemHostCanRead(); + + untracked(() => { + if (onSystemHost && !canRead) { + store.selectAllSiteContent(); + } + }); + }); + }, + onDestroy() { + systemHostGate?.destroy(); + } + }; + }) ); diff --git a/core-web/libs/portlets/dot-content-drive/portlet/src/lib/store/features/site-permissions/withSitePermissions.spec.ts b/core-web/libs/portlets/dot-content-drive/portlet/src/lib/store/features/site-permissions/withSitePermissions.spec.ts index 1b02c54afcbb..4bf0d38361a2 100644 --- a/core-web/libs/portlets/dot-content-drive/portlet/src/lib/store/features/site-permissions/withSitePermissions.spec.ts +++ b/core-web/libs/portlets/dot-content-drive/portlet/src/lib/store/features/site-permissions/withSitePermissions.spec.ts @@ -3,6 +3,8 @@ import { createServiceFactory, mockProvider, SpectatorService } from '@openng/sp import { NEVER, of, throwError } from 'rxjs'; import { describe, expect, it, vi } from 'vitest'; +import { HttpErrorResponse } from '@angular/common/http'; + import { DotPermissionsService } from '@dotcms/data-access'; import { DotSite } from '@dotcms/dotcms-models'; @@ -139,3 +141,57 @@ describe('withSitePermissions', () => { }); }); }); + +describe('withSitePermissions — reading System Host', () => { + let spectator: SpectatorService>; + let store: InstanceType; + + const canAddChildren = vi.fn(); + + const createService = createServiceFactory({ + service: sitePermissionsStoreMock, + providers: [mockProvider(DotPermissionsService, { canAddChildren })] + }); + + beforeEach(() => { + canAddChildren.mockReset(); + }); + + it('should treat a refusal as not readable', () => { + // The permissions resource checks READ before it answers and refuses outright when the + // caller does not hold it, so a 403 from this one call is the server saying the user + // cannot see the asset at all -- not merely that they cannot add to it. + canAddChildren.mockReturnValue(throwError(() => new HttpErrorResponse({ status: 403 }))); + spectator = createService(); + store = spectator.service; + + store.loadSystemHostPermissions(); + + expect(store.systemHostCanRead()).toBe(false); + }); + + it('should keep System Host readable when the lookup fails for any other reason', () => { + // A timeout or a 500 says nothing about permissions. Locking someone out of a scope + // because the network hiccuped is worse than showing them an entry the server will + // police anyway -- the listing enforces read permissions on its own. + canAddChildren.mockReturnValue(throwError(() => new HttpErrorResponse({ status: 500 }))); + spectator = createService(); + store = spectator.service; + + store.loadSystemHostPermissions(); + + expect(store.systemHostCanRead()).toBe(true); + }); + + it('should keep System Host readable when the lookup succeeds', () => { + canAddChildren.mockReturnValue(of(false)); + spectator = createService(); + store = spectator.service; + + store.loadSystemHostPermissions(); + + // Answering the add question at all means the read check upstream of it passed. + expect(store.systemHostCanRead()).toBe(true); + expect(store.systemHostCanAddChildren()).toBe(false); + }); +}); diff --git a/core-web/libs/portlets/dot-content-drive/portlet/src/lib/store/features/site-permissions/withSitePermissions.ts b/core-web/libs/portlets/dot-content-drive/portlet/src/lib/store/features/site-permissions/withSitePermissions.ts index 166886fee92c..6b7cfefd94a8 100644 --- a/core-web/libs/portlets/dot-content-drive/portlet/src/lib/store/features/site-permissions/withSitePermissions.ts +++ b/core-web/libs/portlets/dot-content-drive/portlet/src/lib/store/features/site-permissions/withSitePermissions.ts @@ -3,6 +3,7 @@ import { patchState, signalStoreFeature, withMethods, withState } from '@ngrx/si import { rxMethod } from '@ngrx/signals/rxjs-interop'; import { filter, pipe, switchMap, tap } from 'rxjs'; +import { HttpErrorResponse } from '@angular/common/http'; import { inject } from '@angular/core'; import { DotPermissionsService } from '@dotcms/data-access'; @@ -34,6 +35,19 @@ interface WithSitePermissionsState { * the affordances for a destination nobody navigated away from. */ systemHostCanAddChildren: boolean | undefined; + + /** + * Whether the user may see System Host at all. + * + * Read from the same lookup rather than a second call: the permissions resource checks READ + * before it answers anything and refuses outright without it, so a refusal IS the read answer. + * + * `true` until told otherwise, including when the lookup fails for any other reason. The + * listing enforces read permissions server-side regardless, so the worst a wrong `true` does + * is offer an entry that lists nothing — where a wrong `false` would lock someone out of a + * scope they are entitled to because the network hiccuped. + */ + systemHostCanRead: boolean; } /** @@ -48,7 +62,8 @@ export function withSitePermissions() { return signalStoreFeature( withState({ siteCanAddChildren: undefined, - systemHostCanAddChildren: undefined + systemHostCanAddChildren: undefined, + systemHostCanRead: true }), withMethods((store, dotPermissionsService = inject(DotPermissionsService)) => ({ /** @@ -78,9 +93,18 @@ export function withSitePermissions() { tapResponse({ next: (canAddChildren) => patchState(store, { - systemHostCanAddChildren: canAddChildren + systemHostCanAddChildren: canAddChildren, + // An answer at all means the resource's own READ check + // passed upstream of it. + systemHostCanRead: true }), - error: () => patchState(store, { systemHostCanAddChildren: true }) + // A refusal is the read answer; anything else says nothing about + // permissions and must not lock the user out of the scope. + error: (error: HttpErrorResponse) => + patchState(store, { + systemHostCanAddChildren: true, + systemHostCanRead: error?.status !== 403 + }) }) ) ) diff --git a/specs/37426-content-drive-browse-scopes/spec.md b/specs/37426-content-drive-browse-scopes/spec.md index 21ac45bc2a1b..702780320631 100644 --- a/specs/37426-content-drive-browse-scopes/spec.md +++ b/specs/37426-content-drive-browse-scopes/spec.md @@ -118,6 +118,8 @@ Having selected some content, a user drags it onto the **System Host** entry to #### The System Host toggle +- **FR-016a**: A user who may not read System Host MUST NOT be offered it. The sidebar entry is absent rather than disabled — a control with nothing to decide should not be sitting there — and a location naming System Host, however it was arrived at, MUST return the user to All Site Content silently. Silently because they did nothing wrong: usually they followed somebody else's link, and the drive has somewhere sensible to put them. + This is an affordance, not a defence. The listing already enforces read permissions server-side (FR-011), so what this removes is a door that opens onto an empty room, not an exposure. Accordingly a lookup that fails for any reason other than an outright refusal MUST leave the scope available: a timeout says nothing about permissions, and locking someone out of a scope they hold is the worse error. - **FR-017**: The control that decides whether System Host content appears MUST live in a bar above the listing rather than among the filter chips, beside a sentence naming what the listing is currently showing. The sentence MUST agree with the control: with System Host included it reads "All Files in site (System Host shared files included)", and with it excluded, "…excluded". Links already in circulation that carry the control's value MUST keep restoring correctly. The Asset Picker keeps the chip it has always had, labelled "Show System Host"; this requirement governs Content Drive only. - **FR-018**: The bar MUST be offered only while **All Site Content** is selected — the site root and System Host each answer the question it asks simply by being chosen, leaving its sentence nothing to qualify and its toggle nothing to decide. It MUST open and close by height rather than appearing and vanishing, so the listing is pushed down instead of jumping under the pointer. It MUST NOT show an item count: the listing endpoint pages by cursor and returns no total, so any number shown there would be invented. - **FR-019**: The control MUST retain its value while another browse scope is selected, so returning to **All Site Content** restores the user's previous choice rather than resetting it. From 7366fc45c659e29340ae030ac592a8b64e39a498 Mon Sep 17 00:00:00 2001 From: Jalinson Diaz Date: Thu, 17 Sep 2026 11:37:22 -0300 Subject: [PATCH 43/44] test(content-drive): stop sampling the scope bar mid-animation (#37426) Two of the three e2e failures were fixed last push; this is the third. `scopeBarIsOpen()` read the slot's height once and returned a boolean, so Playwright had nothing to retry. The bar closes over 300ms while `selectSystemHost` resolves on the listing response, which lands while it is still shrinking -- measured on a live instance at 31px, 19px at 120ms, 0 at 900ms. The test therefore caught it mid-close and called it open. Replaced with `expect(slot).toBeHidden()`, which retries until it settles. The assertion is against the slot rather than the bar inside it because Playwright's visibility ignores clipping by an ancestor: the bar keeps its own box while the slot has squeezed it to nothing. The repo's own e2e conventions say this in as many words -- avoid a boolean read for a wait, use `expect()` -- and this helper was written against that rule. Co-Authored-By: Claude Opus 5 (1M context) --- .../dotcms-ui-e2e/src/pages/contentDrive.page.ts | 13 +++++-------- .../content-drive-browse-scopes.spec.ts | 6 +++++- 2 files changed, 10 insertions(+), 9 deletions(-) diff --git a/core-web/apps/dotcms-ui-e2e/src/pages/contentDrive.page.ts b/core-web/apps/dotcms-ui-e2e/src/pages/contentDrive.page.ts index 84865c528d50..71fcfb31b679 100644 --- a/core-web/apps/dotcms-ui-e2e/src/pages/contentDrive.page.ts +++ b/core-web/apps/dotcms-ui-e2e/src/pages/contentDrive.page.ts @@ -34,6 +34,7 @@ export class ContentDrivePage { readonly statusToast: Locator; readonly statusToastSummary: Locator; readonly scopeBar: Locator; + readonly scopeBarSlot: Locator; readonly scopeBarSummary: Locator; readonly scopeBarToggle: Locator; readonly toasts: Locator; @@ -63,6 +64,10 @@ export class ContentDrivePage { // Its slot is always in the DOM and opens by height, so visibility is the question to ask // rather than presence. this.scopeBar = page.getByTestId('scope-bar'); + // The wrapper that opens and closes by height. Assert against this rather than the bar + // inside it: Playwright's visibility ignores clipping by an ancestor, so the bar itself + // still measures as visible while this has squeezed it to nothing. + this.scopeBarSlot = page.getByTestId('scope-bar-slot'); this.scopeBarSummary = page.getByTestId('scope-bar-summary'); this.scopeBarToggle = page.getByTestId('scope-bar-toggle'); this.toasts = page.locator('.p-toast-message'); @@ -360,14 +365,6 @@ export class ContentDrivePage { ); } - /** Whether the scope bar is open, which is a question about height rather than presence. */ - async scopeBarIsOpen(): Promise { - const slot = this.page.getByTestId('scope-bar-slot'); - const box = await slot.boundingBox(); - - return (box?.height ?? 0) > 0; - } - /** Flips the System Host toggle and waits for the listing it re-requests. */ async toggleSystemHostInScopeBar() { const listing = this.page.waitForResponse( diff --git a/core-web/apps/dotcms-ui-e2e/src/tests/content-drive/content-drive-browse-scopes.spec.ts b/core-web/apps/dotcms-ui-e2e/src/tests/content-drive/content-drive-browse-scopes.spec.ts index 5964dca9be13..067c0224007d 100644 --- a/core-web/apps/dotcms-ui-e2e/src/tests/content-drive/content-drive-browse-scopes.spec.ts +++ b/core-web/apps/dotcms-ui-e2e/src/tests/content-drive/content-drive-browse-scopes.spec.ts @@ -293,7 +293,11 @@ test.describe('Content Drive Browse Scopes', () => { await expect(drive.scopeBarToggle).toBeVisible(); await drive.selectSystemHost(); - expect(await drive.scopeBarIsOpen()).toBe(false); + // Retried rather than sampled once: the bar closes over 300ms and `selectSystemHost` + // resolves on the listing response, which lands while it is still shrinking. A one-shot + // read of its height sees it mid-animation and calls it open — which is what this test + // did, and it is the exact thing the repo's own conventions warn against. + await expect(drive.scopeBarSlot).toBeHidden(); }); test('flips the sentence with the System Host toggle @critical', async ({ adminPage }) => { From 6f26f0990c9a2cecce9b32793daa0db5715ec30a Mon Sep 17 00:00:00 2001 From: Jalinson Diaz Date: Thu, 17 Sep 2026 12:18:40 -0300 Subject: [PATCH 44/44] fix(content-drive): take the collapsed scope bar out of the tab order (#37426) Closed, the bar is zero-height and transparent -- which hides it from the eye and from nothing else. Its toggle stayed tabbable and stayed in the accessibility tree in every scope that does not show the bar, so a keyboard user tabbing through the site root or System Host reached a control for a filter that was not on screen. `inert` on the animating wrapper removes it from the tab order and the accessibility tree together, leaving the height transition untouched; `aria-hidden` beside it covers anything not yet honouring `inert`. Raised by the automatic review, and invisible to the suite as it stood: the bar had no test that changed scope, and the shell spec mocked `$allSiteContentSelected` as a constant `false`, so the template could never react. That mock is a real signal now, which is what lets the two new tests -- inert while closed, not inert while open -- mean anything. Co-Authored-By: Claude Opus 5 (1M context) --- .../dot-content-drive-shell.component.html | 9 +++++- .../dot-content-drive-shell.component.spec.ts | 29 ++++++++++++++++++- 2 files changed, 36 insertions(+), 2 deletions(-) diff --git a/core-web/libs/portlets/dot-content-drive/portlet/src/lib/dot-content-drive-shell/dot-content-drive-shell.component.html b/core-web/libs/portlets/dot-content-drive/portlet/src/lib/dot-content-drive-shell/dot-content-drive-shell.component.html index d9c24826c509..8df8aa4b7541 100644 --- a/core-web/libs/portlets/dot-content-drive/portlet/src/lib/dot-content-drive-shell/dot-content-drive-shell.component.html +++ b/core-web/libs/portlets/dot-content-drive/portlet/src/lib/dot-content-drive-shell/dot-content-drive-shell.component.html @@ -41,13 +41,20 @@ [class]=" $allSiteContentSelected() ? 'grid-rows-[1fr] opacity-100' : 'grid-rows-[0fr] opacity-0' " + [attr.inert]="$allSiteContentSelected() ? null : ''" + [attr.aria-hidden]="$allSiteContentSelected() ? null : 'true'" data-testid="scope-bar-slot"> + `overflow-hidden` belongs on this inner element: it is the one being squeezed. + + Closed means zero-height and transparent, which hides the bar from the eye but not from + the keyboard: without `inert` its toggle stays tabbable, and stays in the accessibility + tree, in every scope that does not show the bar. `inert` removes it from both; the + `aria-hidden` beside it covers anything that does not honour `inert` yet. -->
diff --git a/core-web/libs/portlets/dot-content-drive/portlet/src/lib/dot-content-drive-shell/dot-content-drive-shell.component.spec.ts b/core-web/libs/portlets/dot-content-drive/portlet/src/lib/dot-content-drive-shell/dot-content-drive-shell.component.spec.ts index ffcfa2f80f8c..abee70af918c 100644 --- a/core-web/libs/portlets/dot-content-drive/portlet/src/lib/dot-content-drive-shell/dot-content-drive-shell.component.spec.ts +++ b/core-web/libs/portlets/dot-content-drive/portlet/src/lib/dot-content-drive-shell/dot-content-drive-shell.component.spec.ts @@ -109,6 +109,7 @@ const canAddChildrenSignal: WritableSignal = signal(true); // as the one above: two store mocks in this file read it from describes with no shared `beforeEach`. const siteCanAddChildrenSignal: WritableSignal = signal(undefined); const systemHostCanReadSignal = signal(true); +const allSiteContentSelectedSignal = signal(false); describe('DotContentDriveShellComponent', () => { let spectator: Spectator; @@ -228,6 +229,7 @@ describe('DotContentDriveShellComponent', () => { canAddChildrenSignal.set(true); siteCanAddChildrenSignal.set(undefined); systemHostCanReadSignal.set(true); + allSiteContentSelectedSignal.set(false); filtersSignal = signal({}); statusSignal = signal(DotContentDriveStatus.LOADING); dialogSignal = signal(undefined); @@ -318,7 +320,7 @@ describe('DotContentDriveShellComponent', () => { selectedNode: vi.fn(), setSelectedNode: vi.fn(), // The shell renders the sidebar, which asks the store which entry is selected. - $allSiteContentSelected: vi.fn().mockReturnValue(false), + $allSiteContentSelected: allSiteContentSelectedSignal, $systemHostSelected: systemHostSelectedMock, // Mirrors the store's own computed rather than hardcoding an answer, so these // tests keep driving the destination through the signals they already control: @@ -2057,6 +2059,31 @@ describe('DotContentDriveShellComponent', () => { }); }); + describe('the scope bar slot', () => { + it('should take the bar out of reach while it is closed', () => { + // Closed is zero-height and transparent, which hides it from the eye and from nothing + // else: the toggle inside stayed tabbable and stayed in the accessibility tree in + // every scope that does not show the bar. + allSiteContentSelectedSignal.set(false); + spectator.detectChanges(); + + const slot = spectator.query(byTestId('scope-bar-slot')); + + expect(slot?.hasAttribute('inert')).toBe(true); + expect(slot?.getAttribute('aria-hidden')).toBe('true'); + }); + + it('should put it back in reach when all site content is selected', () => { + allSiteContentSelectedSignal.set(true); + spectator.detectChanges(); + + const slot = spectator.query(byTestId('scope-bar-slot')); + + expect(slot?.hasAttribute('inert')).toBe(false); + expect(slot?.hasAttribute('aria-hidden')).toBe(false); + }); + }); + describe('reporting a run in flight', () => { // Moved here with the effect itself. The toolbar raised this while it still drew the // indicator in its filter row; now that the status is a toast rendered by the shell, the