Skip to content

Workflow Center: select all results across pages, with per-row exclusion #36981

Open

Description

@rjvelazco

Description

Content Drive can only ever act on the rows visible on the current page. There is no way to act on a whole result set, which the legacy content-search portlet has supported for years. An editor who filters down to 3,400 items and wants to publish them has to paginate 170 times at 20 rows a page — and even that does not work, because the selection is discarded on every page change.

This is the missing capability behind #33338 (product Feature, with mockup). It is a prerequisite for the bulk workflow work in this epic being useful at real content volumes: #36817, #36844, #36845 and #36819 all operate on a selection that today cannot exceed one page.

Current state

Capability Status Where
Per-row checkbox ✅ Implemented dot-folder-list-view.component.html:74 — <p-tableCheckbox>
Header checkbox — selects current page ✅ Already works dot-folder-list-view.component.html:32 — <p-tableHeaderCheckbox>
Selection survives page change ❌ Wiped dot-content-drive.store.ts:338 — loadItems() patches selectedItems: []; the table repeats it at dot-folder-list-view.component.ts:308-311
"Select all N results" escalation ❌ Missing —
Bulk endpoints accept a query instead of inodes ✅ Already supported FireBulkActionsForm, BulkActionForm; WorkflowResource:937 / :1003 / :1094
Frontend models carry query ✅ Already declared dot-action-bulk-request-options.model.ts:9, dot-bulk-actions.model.ts:13

So the two halves of the contract already exist and were designed for this. There is even a TODO marking the exact insertion point — dot-content-drive-action-center.component.ts:576-577:

"Sends inodes rather than a Lucene query. If Content Drive later supports selecting beyond the current page, this is where the { query } variant goes — the endpoint accepts either."

What the legacy portlet did

view_contentlets_js_inc.jsp implements exactly the two-stage escalation this issue asks for, and is the reference behavior:

  1. checkUncheckAll() (:1836) — header checkbox selects the visible page only, then calls selectAllContentsMessage().
  2. selectAllContentsMessage() (:1860) — shows "all N content item(s) on this Page are selected" and, only when perPage < totalContents, appends a Select all <totalContents> content-s link.
  3. selectAllContents() (:1876) — sets fullCommand = "true", shows the total and a Clear Selection link.
  4. updateUnCheckedList() (:93) — while fullCommand is true, unchecking a row accumulates into unCheckedInodes, an exclusion list layered on the query.
  5. getSelectedInodesFromList() (:1344-1375) — sends "query_" + luceneQueryRaw + " -(inode:… inode:…)" instead of a list of inodes.

i18n keys already exist and should be reused: Select-all (Language.properties:4258), contents-on-this-page-are-selected (:841), Clear-Selection, content-s.

The two real gaps

Both are on POST /api/v1/drive/search, and both must be closed for this to work:

  1. No total count. The response (DotContentDriveSearchResponse) carries folderCount / contentCount, which are per-page counts — confirmed in BrowserAPIImpl.java:1611-1659. The UI therefore fabricates a total purely to keep the paginator's Next button alive (dot-content-drive-shell.component.ts:435-448):

    // The API uses cursor-based pagination and does not return a total count.
    return page?.hasMoreContent || page?.hasMoreFolders
        ? limit * (currentPage + 1)          // fake "one page beyond"
        : limit * (currentPage - 1) + items.length;

    A Select all 3,400 results link cannot be rendered from a fabricated number.

  2. No Lucene query for the client to send. /api/v1/drive/search takes a structured DriveRequestForm (assetPath, contentTypes, baseTypes, language, workflow, userSearchable, cursors), and BrowserAPIImpl resolves it SQL-first / hybrid DB+ES, not as one Lucene query. buildPureESQuery(BrowserQuery) (BrowserAPIImpl.java:596) is close and doPureESQuery even computes an exact total via contentletAPI.indexCount(...) (:573) — but it is private, and PURE_ES explicitly cannot express Content Drive's field filters (BrowserAPIImpl.java:482-492):

    DotRuntimeException("Content Drive field filters (userSearchable) are not supported under the PURE_ES heuristic...")
    

Proposed design

Introduce a selection descriptor in the store so every consumer reads one shape instead of assuming an array of rows:

type DotContentDriveSelection =
  | { mode: 'items'; items: DotContentDriveItem[] }
  | { mode: 'query'; query: string; excludedInodes: string[]; total: number };

Assumptions made in writing these criteria — flag on review if any are wrong:

  • The total shown in the link is the contentlet total; folders are excluded from select-all, consistent with excludeFolders in action-center.ts:206.
  • Where the requested filter combination cannot be expressed as Lucene (the userSearchable / Tag cases above), the correct behavior is to not offer the select-all link rather than to offer a query that silently drops a filter.
  • The Action Center preview under a query selection shows per-content-type counts from getBulkActions({ query }) plus a sampled first page of rows, explicitly labeled as a sample.

Acceptance Criteria

Backend — /api/v1/drive/search

  • The search response returns an exact total contentlet count for the current filter set, distinct from the existing per-page contentCount.
  • The search response returns the Lucene/ES query equivalent of the current filter set, or an explicit indicator that no equivalent can be produced for this filter combination.
  • When the filter set includes predicates that cannot be expressed in Lucene (e.g. userSearchable field filters, Tag predicates routed through SQL — BrowserAPIImpl.java:482-492), the response says so rather than returning a query that silently omits them.
  • A new paged endpoint resolves a query selection to contentlet identifiers, so consumers that require inodes can materialize the selection in batches.
  • Computing the total does not regress search latency on the default (no-filter) Content Drive view; measured before/after.
  • WorkflowHelper.fireBulkActionsNoReturn() — the query branch at :451-452 has no return/else, so a request carrying both query and contentletIds fires the query path and then the ids path, double-firing. Fixed, with a test asserting a single execution.

Frontend — selection state

  • selectedItems is replaced by (or wrapped in) a selection descriptor supporting both items and query modes; store.setSelectedItems keeps working for the items path.
  • A page-only selection persists across page navigation instead of being cleared by loadItems() (dot-content-drive.store.ts:338) and the table effect (dot-folder-list-view.component.ts:308-311).
  • Changing the search query, filters, folder, or site clears the selection in both modes — a query selection must never outlive the filters that defined it.
  • Changing sort order or rows-per-page does not clear the selection.

Frontend — the two-stage escalation

  • Checking the header checkbox selects every contentlet on the current page (existing behavior) and now surfaces a selection bar reading "All N items on this page are selected."
  • The bar shows a "Select all N results" link only when the header checkbox is fully checked and the total exceeds the current page size. It is absent when the result set fits on one page.
  • The link is absent when the backend reports that no Lucene equivalent exists for the current filters, with a tooltip explaining why select-all is unavailable.
  • Clicking the link switches to mode: 'query', updates the bar to "All N results are selected", and shows a "Clear Selection" link.
  • "Clear Selection" returns to an empty items selection and hides the bar.
  • Unchecking an individual row while in query mode adds its inode to excludedInodes, decrements the displayed count, and keeps the selection in query mode.
  • Re-checking a previously excluded row removes it from excludedInodes and restores the count.
  • Unchecking the header checkbox while in query mode clears the whole selection (does not fall back to a page selection).
  • Folder rows are never included by select-all, and the count reflects contentlets only.
  • The selection count in the Action Center header (content-drive.action-center.items-selected) reflects the query total minus exclusions.

Consumers

Sad path and edge cases

  • Select-all over a result set the user only partially has permission on fires only on permitted items and reports the skipped count — it does not fail the whole batch.
  • A 422 from the bulk fire endpoint (entire batch skipped on scheme mismatch — WorkflowResource:1003) surfaces as an explanatory message, not a generic error.
  • If the resolve-identifiers call fails partway, the user is told how many items were processed and that the operation was incomplete.
  • Content added or removed between selecting and firing does not error; the fired total is reported as actual, and any divergence from the displayed count is surfaced.
  • A select-all of zero results is impossible — the link is not rendered when the total is 0.

Tests

  • Store unit tests: descriptor transitions (page → query → cleared), exclusion add/remove, and the clear-on-filter-change vs keep-on-sort-change rules.
  • Component tests for the selection bar: link visibility thresholds, count rendering, Clear Selection.
  • Backend tests for the exact total, the query-equivalent output, the not-expressible case, and the fireBulkActionsNoReturn double-fire fix.
  • E2E covering select-all → fire a workflow action → verify the result count, added under apps/dotcms-ui-e2e/src/tests/content-drive/ (which currently has no selection coverage).

Priority

Medium

Additional Context

Relationships

Key files

Area Path
Selection store core-web/libs/portlets/dot-content-drive/portlet/src/lib/store/dot-content-drive.store.ts
State shape .../portlet/src/lib/shared/models.ts:184-225
Table + header checkbox core-web/libs/portlets/dot-content-drive/ui/src/lib/dot-folder-list-view/
Faked total .../portlet/src/lib/dot-content-drive-shell/dot-content-drive-shell.component.ts:435-448
Action Center + the TODO .../portlet/src/lib/components/dialogs/dot-content-drive-action-center/dot-content-drive-action-center.component.ts:576
Action execution .../portlet/src/lib/store/features/action-execution/withActionExecution.ts
Selection helpers .../portlet/src/lib/utils/action-center.ts
Search endpoint dotCMS/src/main/java/com/dotcms/rest/api/v1/drive/ContentDriveResource.java:82
Query building / totals dotCMS/src/main/java/com/dotcms/browser/BrowserAPIImpl.java:470-596, :1611-1659
Bulk forms dotCMS/src/main/java/com/dotcms/workflow/form/FireBulkActionsForm.java, BulkActionForm.java
Bulk helper (+ the double-fire bug) dotCMS/src/main/java/com/dotcms/workflow/helper/WorkflowHelper.java:159-200, :417-457
Legacy reference dotCMS/src/main/webapp/html/portlet/ext/contentlet/view_contentlets_js_inc.jsp:93, :1344-1375, :1836-1885

Open question for planning

doPureESQuery gets an exact total for free via contentletAPI.indexCount(...), but the default heuristic is HYBRID_SINGLE_CHUNKED_QUERY_ES (SQL page + per-chunk ES filter), which has no equivalent. Whether the total comes from a second count query, from a widened PaginatedContents, or only for filter sets that can route through pure ES, is a design decision for /speckit-plan — and it interacts directly with the "not expressible as Lucene" criterion above.

Activity

  1. added theissue type on Aug 10, 2026
  2. changed the title [-]Content Drive: select all results across pages, with per-row exclusion[/-] [+]Workflow Center: select all results across pages, with per-row exclusion[/+] on Aug 10, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Type

    Projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions