Skip to content

feat(content-drive): Status filter (Archived, Unpublished, Locked), three regression fixes, and a Refresh role gate (#37066) - #37216

Open
zJaaal wants to merge 34 commits into
mainfrom
issue-37066-content-drive-status-filter-impl
Open

feat(content-drive): Status filter (Archived, Unpublished, Locked), three regression fixes, and a Refresh role gate (#37066)#37216
zJaaal wants to merge 34 commits into
mainfrom
issue-37066-content-drive-status-filter-impl

Conversation

@zJaaal

@zJaaal zJaaal commented Aug 25, 2026

Copy link
Copy Markdown
Member

Implementation PR, stacked on #37170 (the approved spec) — stack #37217. Review #37170 first; this diff is the feature only.

Resolves #37066.

Ready for a Docker image. Backend + frontend are complete and the unit/Jest suites are green. The integration tests have not been run — see Verification below. Manual testing against a build is genuinely useful here.

What it does

An optional status array on POST /api/v1/drive/search, plus a multiselect chip in the Content Drive toolbar. Selected statuses combine with OR — checking more boxes returns more content, matching the content-type and locale filters beside it.

[shared assets] [content type] [workflow] [status] [locale]

The five things most worth reviewing

1. One OR-ed group, with the archived baseline OUTSIDE it.

  and cvi.deleted = false                                        -- baseline, unless ARCHIVED selected
  and ( cvi.live_inode is null or cvi.locked_by is not null )    -- the selected statuses

Folding the baseline into the group would make [UNPUBLISHED, LOCKED] read (deleted = false or …) and match nearly every row — a filter that silently stops filtering.

2. Lucene needs an explicit group. + means REQUIRED, so +deleted:true +live:false is an AND. buildPureESQuery emits +(deleted:true OR live:false). Caught by @nollymar in review on #37170; it follows the convention already in that same method (+(conhost:… OR conhost:SYSTEM_HOST)).

3. An empty selection emits nothing at all. Not an empty group — and ( ) is a SQL syntax error and +() is invalid Lucene. This is the default path: every drive search that exists today sends no status, so an unfiltered request stays byte-identical.

4. showWorking now covers ARCHIVED/UNPUBLISHED. Neither state has a live version, so without this the query joins live_inode and returns nothing — silently, with no error. LOCKED doesn't need it; a locked item may well be live.

5. ARCHIVED suppresses the archive-step reconciliation the same way showArchived does, so it can't contradict appendWorkflowQuery's per-branch cvi.deleted.

A real bug the tests caught

parseStatuses originally built its 400 message with String.format and handed the result to BadRequestException. But HttpStatusCodeException runs String.format over the message again — so status: ["50%"] raised UnknownFormatConversionException and surfaced as a 500 instead of a 400. User input was reaching a format string. The value now travels as a format argument, with a regression test.

The same test also showed BadRequestException.getMessage() returns "HTTP 400 Bad Request"; the useful text is in the error-message header, which is what a client actually sees.

Regressions found while testing, fixed here

Three Content Drive issues surfaced during manual testing of this filter. Carried in rather than deferred, at @zJaaal's call, so the portlet ships whole.

1. A loading dialog could not be closed. The folder dialog's footer sat inside @if ($formReady()), so while the form loaded there was no Cancel button and no way out but the backdrop. The footer is now outside the guard and Cancel always renders; only the submit button waits on the form.

2. The toolbar lost its bottom border. Traced to border-none! on the toolbar, added to strip p-toolbar's default border on the other three sides. Narrowed to border-x-0! border-t-0!, which keeps the bottom edge that separates the toolbar from the table. Scoped to Content Drive only.

3. The context menu was one undifferentiated list. It now reads as three named groups:

ACTIONS                 <- caption
Edit Content
Lock
Push Publish
Add to Bundle
────────────────
WORKFLOWS               <- caption
Assign Workflow
Save
Save / Publish
────────────────
Archive                 <- separator alone, no caption

A folder gets the same treatment: an Actions caption, and Delete held apart below a separator. There the caption is unshifted after the fact rather than pushed first — every entry on a folder is permission-gated, so that is the only point where the group is known to be non-empty.

On the caption itself: p-contextMenu has no group-label class at all — its style map is item, separator, submenu, submenuIcon, and it renders items as a flyout. PrimeNG's inline group label (p-menu-submenu-label) lives on p-menu. So the caption is a regular item wearing that class, made inert by disabled (PrimeNG's isValidItem skips disabled items, keeping it out of keyboard navigation) plus pointer-events-none (it would otherwise take a click and close the menu). Three utilities cancel what the surrounding styles impose: p-0! so it aligns on the item padding instead of being inset twice, pointer-events-none, and text-inherit on the content wrapper, since .p-contextmenu-item-content sets color on a descendant and would win.

Switching wholesale to p-menu for its native grouped model was considered and rejected: p-menu popup calls absolutePosition(container, target) and anchors to an element, while p-contextMenu positions at event.pageX/pageY. A right-click menu opening at the row instead of the cursor is a worse regression than a faked caption row.

The destructive split reads hasArchiveActionlet || hasDeleteActionlet || hasDestroyActionlet — the action's actual sub-actionlets, never its name, so a scheme's "Retire this blog" or "Purge" lands there too. A parameterized test names the actions nothing like Archive or Delete precisely to hold that line. A folder's Delete gets the same separator; its gate is strictly narrower than Edit Folder's, so it can never lead the menu.

Finer grouping than this is not possible from the data: the API exposes no actionlet class names, no category and no tag, order is a within-scheme sort index, and icon is admin-authored free text. Anything more would be guesswork that breaks on custom schemes.

Frontend notes

  • The whole row is clickable, label included. First cut bound [ngModel] on the listbox without (ngModelChange), so only the checkbox responded. Regression test clicks the label specifically.
  • decodeByFilterKey needs the explicit status entry. Without it a lone status:ARCHIVED decodes to the string 'ARCHIVED' — whose .length is 8, so every ?.status?.length guard misreads it and the filter looks fine until someone selects exactly one status.
  • Placement: after Workflow, before Locale. Content type and workflow stay adjacent because the workflow filter derives its scheme list from the content-type selection — the row's only real dependency.
  • archived: false pin removed from the store request (FR-019); the endpoint already defaults it, and pinning it would contradict an Archived selection.
Screen.Recording.2026-08-26.at.10.24.34.AM.mov

Two questions reviewers keep asking

Answering both here rather than in the source — the code comments already carry their weight.

Why a new ContentStatus enum, when WorkflowState already has these names?

com.dotmarketing.portlets.workflows.model.WorkflowState is NEW, LOCKED, UNLOCKED, PUBLISHED, UNPUBLISHED, ARCHIVED, LISTING, EDITING — all three of ours, same spelling. It looks like a duplicate and isn't:

  • It is a different concept. Its own javadoc: the show_on set deciding whether a workflow action renders. That is why it also carries LISTING and EDITING — view contexts, not states a contentlet can be in, with nothing to resolve against in a query. Reusing it would put status: ["EDITING"] in the public API contract.
  • Its parsing contradicts ours. WorkflowState.toSet catches any exception and returns an empty set, so one bad value silently drops the whole filter and returns a wider result than the caller asked for. This filter deliberately returns a 400 instead — the behaviour the tests and this PR's review cycle settled on.
  • It would couple the API surface to workflow internals. Adding a show_on value would silently widen /v1/drive/search's accepted input. We implement three states; that enum has eight and grows for unrelated reasons.

Same words, different job.

Why does decodeFilters drop every empty array, not just status?

Raised by the automated review, and fair: the behaviour is general while the comment reads status-specific. The general rule is intended.

Every consumer reads these through ?.length, so undefined and [] already mean the same thing — there is no behavioural difference to preserve. Storing the key would re-encode a bare status: into the URL for the next decode to trip over. status is simply what surfaced it: sanitizing status:BOGUS is the first decode that can legitimately produce an empty array.

Verification

Suite Result
ContentDriveHelperStatusTest (unit) ✅ 8/8
Frontend (portlets-content-drive, full) ✅ 1304/1304
Lint ✅ clean
tsc --noEmit ✅ no new errors (35 pre-existing, none in touched files)
ContentDriveStatusFilterTest (integration, 16 cases) 16/16 in CI
ContentDriveWorkflowArchiveStepTest (regression guard) ✅ green after b09fb47 — see below

The integration class covers each status alone, every pair, all three, the empty default, the never-shrinks property and the archive-step regression. Registered in MainSuite3a.

MainSuite 3a has now run, and it caught one thing — a bad assertion of mine, not a code defect. 1 failure out of 803, deterministic across all three retries: testUnpublishedStatusWithArchiveStepExcludesArchivedContent claimed UNPUBLISHED + an archive-target step must exclude archived content. False — an archive-target step makes appendWorkflowQuery admit cvi.deleted = true rows in that branch by itself, with no status involved (testMixedFilterScopesArchivedToArchiveBranch pins exactly that and passes), and ContentletAPI.archive unpublishes so the row satisfies live_inode is null too. Both clauses match; returning it is correct.

Replaced in b09fb47 by two tests rather than one: …StillReturnsArchivedContent asserts what actually holds, and …KeepsTheGlobalArchivedBaseline is the real complement, using a normal step where nothing lifts the baseline. Strictly stronger than what it replaced — it still guards the baseline and now documents the interaction that misled me.

The fix itself was not verified locally: com.dotcms.tika-api's pom in the local repo is unflattened, so :dotcms-integration fails dependency resolution before any test runs. CI is the verification.

PURE_ES parity holds for ARCHIVED and LOCKED, and deliberately not for UNPUBLISHED — see below.

Two things I won't paper over

The TDD gates for US2–US5 are not satisfied. I implemented all three SQL disjuncts in one switch rather than story-by-story, because splitting them leaves states where selecting LOCKED parses fine and filters on nothing. Their tests were therefore written against working code — characterization, not true Red. US1's gates were honored properly (Red confirmed, then implementation).

UNPUBLISHED means something slightly different under PURE_ES, and we accepted that. It means no live version exists — a question about the content, not about one version. The index stores live per version, so a published item with newer unpublished edits has a working document carrying live:false, which the index query matches and cvi.live_inode is null does not. There is no index-side fix short of redefining the status per-version, which would degrade the default path to match a limitation of a strategy nobody runs (PURE_ES is opt-in and is not set in any config file in this repo). ADR-0018 already routes structural predicates to the database for exactly this reason and states PURE_ES forfeits that guarantee. Recorded as FR-009a in the spec, with FR-009 and SC-005 narrowed to match, and a predicate-comparison table in contracts/. ARCHIVED and LOCKED are unaffected.

openapi.yaml is unchanged, deliberately. /v1/drive has zero entries in the generated spec, unlike /v1/folder (8), /v1/browser (4) and /v1/workflow (45) — ContentDriveResource is excluded from OpenAPI generation entirely. The @Operation text was updated and is good source documentation, but it does not reach the yaml. Pre-existing gap; flagging rather than working around it, and worth its own ticket.

Unrelated polish: Refresh is now role-gated in the UI (#36845)

Not part of the status-filter spec. Carried here rather than held back, so it is in the demo build.

QA on POST /api/v1/content/_bulkrefresh turned up that a non-admin sees Refresh in Quick Actions,
fires it, and gets a 403. That was a deliberate call at the time: BulkRefreshHelper.canRefresh
reads as Power User OR Administrator, and the browser cannot tell whether someone is a Power User,
so gating on isAdmin looked like it would hide the action from people entitled to it.

It does not, because the Power User half never fires. It resolves the role key "CMS Power User",
no role ships with that key, loadRoleByKey answers null, and RoleFactoryImpl returns false for a
null role. What survives is doesUserHaveRole(user, loadCMSAdminRole()), which is character-for-character
User.isAdmin(), which is what already feeds currentUserIsAdmin in the store. Client and server
evaluate the same expression, so the row can predict the refusal rather than discover it.

Disabled, not hidden — the same treatment Push Publish gets with no environment configured. The row
keeps saying the capability exists; the tooltip says who it is for.

Before After
Non-admin Row enabled, fires, 403 Row disabled, tooltip names the requirement
Admin Row enabled, fires Unchanged

Follows the existing gate shape exactly: requiresAdmin on the action def, missingAdminRole computed
in getQuickActions, added to the [disabled] binding, the quickActionHint tooltip and the
onSelectQuickAction guard. Three new getQuickActions tests plus three at component level; five
existing Refresh tests now set the admin precondition, since the behaviour they cover starts after the
gate.

Known limit, left alone: an install that hand-created a role keyed "CMS Power User" would let a
non-admin holder through server-side while this row stays shut. dotCMS ships no such role. The clean fix
is to drop the dead branch from canRefresh so the gate is admin-only by definition, but that is a
change to a server-side authorization path and does not belong in a demo-eve commit. Worth its own ticket.

Also touched

Renamed the status:published placeholder in existing decodeFilters tests to owner:jane. They used status as a made-up key to exercise unknown-key fallback, and it is now a real key. Changing their expectations to arrays instead would have forced them green while destroying what they test.

Checklist

  • Tests — unit + integration + Jest/Spectator. Integration not yet executed locally (see Verification)
  • Translations — four content-drive.status-filter.* keys in Language.properties, plus content-drive.action-center.requires-admin for the Refresh gate
  • Security Implications Contemplated — input is a closed enum, validated and rejected with a 400, never interpolated into SQL. The format-string bug above was found and fixed. Permission filtering untouched, so the filter grants no new visibility

🤖 Generated with Claude Code

…cked) (#37066)

Adds an optional `status` array to POST /api/v1/drive/search plus a multiselect
chip in the Content Drive toolbar. Selected statuses combine with OR: checking
more boxes returns more content, matching the content-type and locale filters.

Backend
- ContentStatus enum (com.dotcms.browser), carried through BrowserQuery exactly
  like workflowSchemeIds.
- appendContentStatusQuery emits ONE OR-ed group, AND-ed against the archived
  baseline that stays outside it. Folding the baseline in would make
  [UNPUBLISHED, LOCKED] read (deleted = false or ...) and match nearly every row
  — a filter that silently stops filtering.
- Empty selection emits nothing at all rather than an empty group: `and ( )` is
  a SQL syntax error and `+()` is invalid Lucene. This is the default path, so
  an unfiltered request stays byte-identical to before.
- buildPureESQuery emits +(a OR b), never concatenated `+` terms — in Lucene `+`
  means REQUIRED, so `+deleted:true +live:false` would be an AND. Caught in
  review by @nollymar.
- ARCHIVED admits archived rows the same way showArchived does, so it suppresses
  the archive-target-step reconciliation instead of contradicting it.
- showWorking is now true for ARCHIVED/UNPUBLISHED: neither state has a live
  version, so the query would otherwise join live_inode and return nothing.
- parseStatuses rejects unknown values with a 400 naming the accepted ones. The
  offending value travels as a format ARGUMENT: HttpStatusCodeException runs
  String.format over the message, so concatenating user input made `status=50%`
  raise UnknownFormatConversionException and surface as a 500. Found by the
  tests; regression guard added.

Frontend
- New chip, placed after Workflow and before Locale. Content type and workflow
  must stay adjacent — the workflow filter derives its scheme list from the
  content-type selection, the row's only real dependency.
- Selection lives in the shared filters bag, so deep link, reload, folder
  browsing, Back/Forward and the legacy-editor round-trip all work unchanged.
- decodeByFilterKey needs the explicit `status` entry: without it a lone
  `status:ARCHIVED` decodes to the STRING 'ARCHIVED', whose .length is 8, and
  every `?.status?.length` guard silently misreads it.
- The listbox owns selection in multiple mode so the whole row — label included
  — is clickable; the checkbox is presentation only.
- Dropped the hardcoded `archived: false` pin; the server already defaults it and
  pinning it would contradict an Archived selection.

Tests
- ContentDriveHelperStatusTest: 8/8 green.
- ContentDriveStatusFilterTest: 13 cases covering each status, the union, the
  never-shrinks property, PURE_ES parity and the archive-step regression.
  NOT YET RUN — needs PostgreSQL + Elasticsearch.
- Frontend 1304/1304 green, lint clean, no new tsc errors.
- Renamed the `status:published` placeholder in existing decodeFilters tests to
  `owner:jane`: they used it to exercise unknown-key fallback, and `status` is
  now a real key. Changing their expectations instead would have destroyed what
  they test.

openapi.yaml is unchanged: /v1/drive has no entries in the generated spec at all
(unlike /v1/folder, /v1/browser and /v1/workflow), so the @operation text does
not reach it. Pre-existing gap, flagged rather than worked around.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@zJaaal zJaaal changed the title feat(content-drive): add the Status filter (Archived, Unpublished, Locked) (#37066) feat(content-drive): Status filter (Archived, Unpublished, Locked) (#37066) Aug 25, 2026
@github-actions github-actions Bot added Area : Backend PR changes Java/Maven backend code Area : Frontend PR changes Angular/TypeScript frontend code labels Aug 25, 2026
@zJaaal zJaaal added the PR: docker image Build & push a per-PR test image to dotcms/dotcms-test label Aug 25, 2026
@zJaaal
zJaaal marked this pull request as ready for review August 25, 2026 16:51
@claude

claude Bot commented Aug 25, 2026

Copy link
Copy Markdown
Contributor

Claude finished @zJaaal's task in 2m 16s —— View job


Code Review

Reviewed the diff against origin/issue-37066-content-drive-status-filter. This is the feature-only diff (spec PR #37170 excluded). I focused on bugs, security, and dotCMS conventions in the changed code, and rechecked every prior review finding.

  • Gather diff vs base branch
  • Review backend (Java) changes
  • Review frontend (Angular) changes
  • Recheck prior review findings
  • Post review

New Issues

No issues found.

The backend query construction is sound:

  • appendContentStatusQuery / appendContentStatusESQuery (BrowserAPIImpl.java) both no-op on an empty set, keeping the default path byte-identical, and both put the disjuncts in one OR group (and (…) / +(… OR …)) rather than AND-ing required terms.
  • The archived-baseline suppression correctly folds ARCHIVED into the admitsArchived condition alongside showArchived and the archive-step guard.
  • parseStatuses values come from a closed enum via ContentStatus.valueOf(...) — never interpolated into SQL — and the 400 message passes user input as a String... format argument, so a %-bearing value can't re-trigger String.format in HttpStatusCodeException. BadRequestException(String, String...) matches the call. Locale.ROOT on toUpperCase is correct.

Frontend:

  • decodeByFilterKey.status sanitizes to real ContentStatus values and decodeFilters drops keys that decode to [], so a hand-edited ?filters=status:BOGUS degrades to no-filter rather than reaching the 400-swallowing catchError — consistent with the store comment.
  • The linkedSignal value-equality + signalMethod sync in dot-content-drive-status-filter.component.ts breaks the write-back cycle at the source rather than relying on incidental reference identity.
  • The folder-dialog footer now sits outside the @formReady gate with Cancel always rendered and only the submit button gated — the loading-with-no-exit bug is fixed.

Resolved

  • dot-content-drive-status-filter.component.ts — reactive signalMethod sync with value-comparing linkedSignal (nicobytes, feat(content-drive): Status filter (Archived, Unpublished, Locked), three regression fixes, and a Refresh role gate (#37066) #37216).
  • AbstractDriveRequestForm.java:363 — javadoc no longer claims status "drops folders"; now states "no side effects on other fields", matching the code (oidacra).
  • ContentDriveResource.java@Operation description updated to "showFolders is honoured as sent" (oidacra).
  • BrowserAPIImpl.java build-log comment — status removed from the list of flags that override showFolders (oidacra).
  • BrowserQuery.java:163 — mixed live:true + ARCHIVED/UNPUBLISHED version-join interaction now documented on showWorking and cross-referenced from live() (oidacra).
  • ContentStatus.java — baseline phrasing corrected to "unless something explicitly lifts it"; UNPUBLISHED now spells out identifier-scoped SQL vs per-version live:false (oidacra).
  • ContentDriveStatusFilterTest — keyword test now asserts against liveItemMatchingKeyword ("archived-decoy"), making the intersection observable (oidacra).
  • ContentDriveWorkflowArchiveStepTestadmitsArchived interaction now pinned by testArchivedStatusWithArchiveStepStillReturnsArchivedContent and its complement (oidacra).
  • dot-content-drive.store.ts:163?filters=status:BOGUS closed at the decoder; the broader catchError swallowing (401/403/5xx into a stale grid) is correctly deferred to a follow-up ticket, not a regression this PR introduces.

Notes (non-blocking, already acknowledged in the PR)

  • The PURE_ES UNPUBLISHED divergence is a spec decision (FR-009a, ADR-0018), not a code defect — ARCHIVED/LOCKED retain parity.
  • After grouping, the if (!actionsMenu.length) guard in dot-folder-list-context-menu.component.ts is now unreachable on the contentlet branch (Edit Content + group label are unconditional). Harmless dead check, not worth a change.

The implementation is careful and well-tested. My one caveat is the same one the PR is transparent about: the integration suite's real verification is CI (MainSuite3a), since local :dotcms-integration resolution is blocked by the unflattened tika-api pom — worth confirming the latest CI run is green before merge.
issue-37066-content-drive-status-filter-impl

zJaaal and others added 2 commits August 25, 2026 14:10
Inodes carry the version state; identifiers do not. The original assertions
could not catch the bug they were written to guard.

selectQuery selects `cvi.<working_inode|live_inode> as inode`, choosing the
column from `showWorking || showArchived`. So which inode comes back is the
evidence that the right VERSION was joined. An identifier is stable across
versions, so if the showWorking derivation ever stopped covering ARCHIVED, the
query would join live_inode — null for archived content — return nothing, and
every identifier-based assertion would still pass.

- driveIdentifiers() -> driveInodes(), reading item.get("inode").
- workingInode(contentlet) re-reads from VersionableAPI at assertion time.
  Avoiding inodes originally because publish/archive/lock mint new versions was
  the wrong conclusion: the fix is to re-read, not to drop the precision.
- liveInode(contentlet) so a test can prove there is no live version.

Adds two cases that only an inode-level check can express:
testArchivedReturnsTheWorkingInodeNotTheLiveOne and
testUnpublishedReturnsTheWorkingInode. Both first assert the live inode is null
— otherwise they would pass vacuously — then assert the working inode is what
the drive returned. That makes the showWorking rule an actual guard rather than
a comment claiming one exists.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Both branches had independently merged main, which made them diverge: impl no
longer contained spec's tip, so the merge base fell back to a main commit and
PR #37216's diff ballooned from 22 files to 46 — sweeping in #37132's
edit-content changes, specs/37132-picker-per-host/spec.md and
.specify/feature.json.

Restoring the parent relationship so the stack's diff is the feature again.
@github-actions

github-actions Bot commented Aug 25, 2026

Copy link
Copy Markdown
Contributor

🐳 PR Docker test image

Latest build for commit aad8e69 pushed to dotcms/dotcms-test:

docker pull dotcms/dotcms-test:pr-37216-issue-37066-content-drive-status-filter-impl
docker pull dotcms/dotcms-test:pr-37216-issue-37066-content-drive-status-filter-impl_aad8e69

zJaaal and others added 5 commits August 25, 2026 15:51
…#37066)

The status filter did nothing. Validation worked — an invalid value still
returned the right 400 — but every selection returned the unfiltered result set
and folders were never suppressed.

Cause: the block mutated the builder AFTER the query had been snapshotted.

    final BrowserQuery browserQuery = builder.build();   // snapshot
    builder.withContentStatuses(...).showFolders(false); // discarded
    return browserAPI.getPaginatedContents(browserQuery);

I anchored the insertion on the Logger.debug call below it. Between writing that
and merging main, main refactored the method to build the query into a local
before logging it, which moved my block from before the build to after it. No
compile error, no test failure — the mutation was simply dropped.

Fix is the ordering: parse and mutate, then build, then use. Also adds a warning
at the build boundary, because nothing in the type system stops this recurring.

Found by manual testing against a deployed image, not by the suite. The unit
tests cover parseStatuses in isolation and pass either way; the frontend tests
never reach the server. ContentDriveStatusFilterTest would have failed on the
first assertion — but it has still never been run.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
… endpoint

The status block no longer forces showFolders(false).

Folders carry no status, so the Content Drive UI stops asking for them once a
status is selected — the store already does this. But making the ENDPOINT
override an explicit showFolders:true is a silent side effect: the response
stops matching the request, and folderCursor/hasMoreFolders end up describing a
folder query the caller never received.

Enforcement now lives only on the frontend, where the product decision belongs.
The endpoint does what it is told.

The integration test is inverted accordingly: it now asserts a status selection
does NOT override an explicit showFolders:true, and that showFolders:false still
suppresses folders.

Note the pre-existing workflow filter still forces showFolders(false) server-side
(ContentDriveHelper:224). Left alone — it is outside this ticket — but the two
filters now behave differently and that is worth reconciling separately.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The store already sends showFolders:false when a status is selected. This adds
the reasoning next to it, so the rule is not "helpfully" pushed back into the
endpoint by a later change.

POST /drive/search honours whatever showFolders it is sent, deliberately, so the
response always matches the request and the folder cursors never describe a query
the caller did not make. Keeping the policy on the client also means that if
folder visibility ever becomes its own control, honouring it is a change to this
one line — no backend refactor, no API contract change.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…re that

distinguishes what UNPUBLISHED means (#37066)

Two findings from the automated review on #37216.

1. ContentStatus.valueOf(status.trim().toUpperCase()) used the JVM default
   locale. Under tr-TR, "unpublished".toUpperCase() is "UNPUBLİSHED" with a
   dotted capital I, which valueOf rejects — a valid lowercase value became a
   400 on Turkish-locale servers only. Now toUpperCase(Locale.ROOT), with a
   regression test that flips the default locale.

2. The reviewer flagged that UNPUBLISHED may mean different things on the SQL and
   PURE_ES paths. Confirmed: ESMappingAPIImpl:523 indexes `live` per VERSION
   (contentlet.isLive()), so a published item with pending working edits has a
   working document carrying live:false. `+working:true +(live:false)` matches
   it; SQL's `cvi.live_inode is null` does not.

   The review suggested verifying with the PURE_ES parity case — but that test
   does not exist. It was listed in tasks.md T031, the PR body and quickstart.md
   as covered and was never written; those claims are corrected here. The fixture
   also had no published-then-edited item, so a parity test would have compared
   two identical answers and passed for the wrong reason.

   Adds that discriminating fixture and pins the intended semantics on the
   default path: a published-then-edited item HAS a live version, so UNPUBLISHED
   must exclude it — "no live version exists", not "this version is not live".

The ES divergence itself is left open: "the identifier has no live version" is
not expressible in a per-document index query. Resolving it is a spec decision,
recorded in tasks.md rather than guessed at here.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@zJaaal

zJaaal commented Aug 26, 2026

Copy link
Copy Markdown
Member Author

Both 🟡 findings are real. Fixed in eabb797 — and the second one surfaced something worse than the review could have known.

🟡 Locale — fixed. Exactly right. "unpublished".toUpperCase() under tr-TR yields UNPUBLİSHED and 400s a valid value on Turkish-locale servers only. Now toUpperCase(Locale.ROOT), with a regression test that flips the default locale rather than trusting the reasoning.

🟡 SQL vs PURE_ES UNPUBLISHED — confirmed, your model of the index is correct. ESMappingAPIImpl:523 sets live from contentlet.isLive(), i.e. per VERSION. So a published item with pending working edits has a working document carrying live:false, which +working:true +(live:false) matches, while SQL's cvi.live_inode is null excludes it. Two different questions: "the identifier has no live version" versus "this version is not the live one".

The verification you suggested could not have run. There is no PURE_ES parity case in ContentDriveStatusFilterTest. It was listed as covered in tasks.md T031, in this PR's body and in quickstart.md — and was never written. That is my error, and the more serious of the two: I have been reporting coverage that does not exist. All three claims are corrected in this commit, with T031 un-ticked and marked ⚠️ NOT DONE.

The fixture was also blind to it — no published-then-edited item, so a parity test would have compared two identical answers and passed for the wrong reason. That fixture now exists, plus a test pinning the intended semantics on the default path: a published-then-edited item HAS a live version, so UNPUBLISHED must exclude it.

The divergence itself is left open deliberately. "The identifier has no live version" is not expressible in a per-document index query — -live:true does not help, because the working doc genuinely carries live:false. So the options are a spec decision, not an implementation one:

  1. Accept it and narrow FR-009/SC-005, leaning on ADR-0018 already stating PURE_ES forfeits consistency guarantees for every criterion
  2. Redefine UNPUBLISHED as "this version is not live" — matches legacy ContentletAjax and ES, but changes what users get on the default path
  3. Reject or degrade UNPUBLISHED under PURE_ES

Recorded in tasks.md rather than guessed at. @zJaaal's call.

On your non-blocking note about LOCKED + live:true: agreed, and it is documented in data-model.md as deliberate — LOCKED does not force working-version scoping because a locked item may well be live, so live:true + LOCKED is the coherent "live content that is locked" query.

Analysis and reply by Claude (Claude Code), posted from @zJaaal's account.

zJaaal and others added 2 commits August 26, 2026 18:16
…trator role (#36845)

The endpoint has always refused a non-admin, but the row did not say so, and the
user found out by firing it and reading a 403.

`BulkRefreshHelper.canRefresh` reads as two checks ORed together, and the first
one never fires: it resolves the role *key* "CMS Power User", no role ships with
that key, `loadRoleByKey` answers null and `RoleFactoryImpl` returns false for a
null role. What survives is `doesUserHaveRole(user, loadCMSAdminRole())`, which
is the same expression as `User.isAdmin()`, which is what feeds
`currentUserIsAdmin` here. So the client can predict the refusal exactly rather
than guess at it, which is what the original decision log doubted.

Disabled rather than hidden, matching Push Publish with no environment: the row
still says the capability exists and the tooltip says who it is for.

Known limit: an install that hand-created a role keyed "CMS Power User" would let
a non-admin holder through server-side while this row stays shut. dotCMS ships no
such role.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@zJaaal zJaaal changed the title feat(content-drive): Status filter (Archived, Unpublished, Locked), plus three regression fixes (#37066) feat(content-drive): Status filter (Archived, Unpublished, Locked), three regression fixes, and a Refresh role gate (#37066) Aug 26, 2026
@zJaaal
zJaaal requested a review from a team as a code owner August 27, 2026 13:51
@github-actions github-actions Bot added Area : CI/CD PR changes GitHub Actions/workflows Area : Documentation PR changes documentation files labels Aug 27, 2026
zJaaal and others added 3 commits August 27, 2026 10:57
)

Documentation only, no behaviour change. Both came out of review and both are
questions the next reader will ask in the same order.

**Why not WorkflowState?** It carries the same three names — LOCKED,
UNPUBLISHED, ARCHIVED — so ContentStatus reads like a duplicate on sight. It is
not: WorkflowState is the `show_on` vocabulary deciding whether a workflow
ACTION renders, which is why it also has LISTING and EDITING, view contexts with
nothing to resolve against in a query. Its `toSet` swallows an unparseable value
and returns an EMPTY set, so one bad entry drops the whole filter and returns a
WIDER result than asked for — the opposite of the 400 this filter contracts for.
Reusing it would also tie the public /v1/drive/search input vocabulary to
workflow internals, so a new show_on value would silently widen the API. Same
words, different job. Now said in the javadoc instead of only in a PR thread.

**Why decodeFilters drops every empty array, not just status.** The automated
review flagged the comment as narrower than the behaviour, and it was right. The
rule is general on purpose: every consumer reads these through `?.length`, so
`undefined` and `[]` already mean the same thing, and storing the key would
re-encode `status:` into the URL for the next decode to trip over. `status` is
only what surfaced it — sanitizing `status:BOGUS` is the first decode that can
legitimately produce an empty array. Comment now describes the rule and names
status as the motivating case rather than the scope.

175 frontend tests pass, tsc clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ing (#37066)"

This reverts 65b3dc7. The existing comments already carry their weight; the
rationale for why ContentStatus is not WorkflowState, and why decodeFilters
drops every empty array rather than only status, belongs in the PR description
where reviewers are asking, not layered onto the source.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@github-actions github-actions Bot removed Area : Documentation PR changes documentation files Area : CI/CD PR changes GitHub Actions/workflows labels Aug 27, 2026
zJaaal and others added 2 commits August 27, 2026 14:13
…37066)

Fixes the deterministic `Frontend Unit Tests` failure. No test was failing —
the job died on `FATAL ERROR: Ineffective mark-compacts near heap limit`, with
zero FAIL lines in a 459k-line log.

The fuel was 3,122 `NG0101: ApplicationRef.tick is called recursively` errors,
each carrying a full stack trace. 2,612 of them entered through
`Spectator.flushEffects` — from just 37 call sites in the shell spec, ~70
warnings per call.

Cause: `TestBed.flushEffects()` is an alias for `TestBed.tick()`, which sets
`appRef.includeAllTestViews = true` and ticks EVERY test view, not just the
fixture under test. Called while change detection is already running, each view
re-enters `tick` and logs. `spectator.detectChanges()` is scoped to the one
fixture and does not.

Measured on the full project suite:

    NG0101      2,823 -> 0
    log lines   340,126 -> 6,743   (98% less)
    tests       1,409 pass, unchanged

Not introduced here. Clean `origin/main` produces 2,492 of the same errors from
a byte-identical shell spec; this PR's extra specs were the straw that crossed
the heap limit, not the cause. Main is one added spec away from the same failure
in anyone's PR, which is why this is fixed rather than worked around with
--max-old-space-size (a flag this repo sets nowhere, and which would hide the
defect).

`dot-content-drive.store.spec.ts` deliberately keeps its 15 `flushEffects`
calls: it is a service spectator with no component fixture, so `detectChanges`
cannot flush its effects — swapping them failed 16 tests. It contributes 1 of
the 2,823 errors.

Scope is this portlet only. The same pattern spans 360 calls across 47 spec
files repo-wide; that migration wants its own PR.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@zJaaal
zJaaal dismissed nicobytes’s stale review August 28, 2026 18:01

Already solved them

@zJaaal
zJaaal added this pull request to the merge queue Aug 28, 2026
Base automatically changed from issue-37066-content-drive-status-filter to main August 28, 2026 18:22
@github-merge-queue
github-merge-queue Bot removed this pull request from the merge queue due to failed status checks Aug 28, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

AI: Safe To Rollback Area : Backend PR changes Java/Maven backend code Area : Frontend PR changes Angular/TypeScript frontend code PR: docker image Build & push a per-PR test image to dotcms/dotcms-test

Projects

Status: No status

Development

Successfully merging this pull request may close these issues.

Content Drive: Status filter (Archived, Unpublished, Locked) on drive search and in the toolbar

4 participants