fix(edit-content): show the workflow comment dialog on first execution (#36883) - #37445
fix(edit-content): show the workflow comment dialog on first execution (#36883)#37445adrianjm-dotCMS wants to merge 7 commits into
Conversation
…Edit Mode (#36883) Spec-Kit PR 1 of 2 — carries spec.md alone. The new (Angular) Edit Content UI skips the workflow input dialog on the first execution of an action that declares an input, for content that has not been saved yet. Root-cause hypothesis: the default/initial-action REST endpoints return WorkflowDefaultActionView, which has no actionInputs[], while the per-inode endpoint returns WorkflowActionView, which does. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…nge (#36883) Verified that every flag createActionInputViews reads is already serialized on the raw WorkflowAction embedded in WorkflowDefaultActionView (commentable, assignable, hasPushPublishActionlet, the two move actionlet flags, nextAssign, roleHierarchyForAssign — none @JsonIgnore). The default/initial-action payload already carries everything needed to derive an action's inputs. This is a fact, not a design decision: the backend-vs-frontend choice stays with /speckit-plan. It matters for review because the spec previously implied a REST contract change on a widely shared endpoint was likely necessary. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…fter review (#36883) Addresses @nicobytes' Q1-Q5 on PR #37437. Q1 and Q2 found real errors; both verified against the code before correcting. Q1 - the Root-Cause Hypothesis named setSelectedWorkflow, which the reported repro never reaches: with a single workflow scheme, initializeNewContent auto-selects it and seeds the actions there. Now names all three seeding points with initializeNewContent as primary, and moves the primary store test to content.feature.spec.ts. Q2 - the post-Reset reproduction claim was reasoned, not observed, and is wrong: currentContentActions is patched from getByInode on every fire (inputs present), and re-picking a scheme on content that has an inode takes setSelectedWorkflow's else branch, which does not overwrite it. Claim and AC-006 removed. Q3 - Push Publish on unsaved content is reachable only if configured into the workflow's first step, and was unverified; dropped as an AC. AC-005 split so a failure points at one cause. Q4 - AC-002 now defines "unchanged" for content that does not exist yet, and covers the absence of a saving indicator while the dialog is open. Q5 - frontend-only is the intended default, not an open fork. AC-008 became AC-009, marked contingent; integration/Postman marked not-expected per ADR-0013. Also withdraws a non-goal whose premise was false: the default-actions endpoints have three call sites, all in libs/edit-content, so no follow-up issue is needed for other consumers. ACs renumbered 001-009; new Clarifications section records Q1-Q5. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
#36883) The new Edit Content UI gates its input wizard on `actionInputs.length`, but the endpoints that serve actions for content with no inode return WorkflowDefaultActionView, which has no actionInputs[] — unlike the per-inode endpoint, which returns WorkflowActionView. So a commentable action fired straight through with no dialog, and only showed one on a second execution, once the store had re-fetched per-inode actions. Derive the array client-side instead. Every flag createActionInputViews reads is already serialized on the raw WorkflowAction, so no REST change is needed: - dot-workflows-actions.utils.ts (new): deriveActionInputs(), a TypeScript mirror of WorkflowResource#createActionInputViews, plus withDerivedActionInputs() which leaves a server-provided array untouched. - dot-workflows-actions.service.ts: applied in getDefaultActions() and getWorkFlowActions() only. getByInode/getByWorkflows/getBulkActions are left alone on purpose — their endpoints build the array server-side, and deriving over them would hide a backend regression instead of surfacing it. libs/edit-content needs no production change: all three affected seeding paths (new content single-scheme, new content multi-scheme, new locale translation) are fixed transitively at the service boundary. Tests: 13 unit tests for the derivation rule, 5 service tests, 4 new component tests for the AC-002 dialog gating, 1 store pass-through test. Red confirmed before implementing (Expected [{body:{},id:'commentable'}], received undefined). Also carries the spec's data-model.md and contracts/, which .gitignore keeps tracked while plan/research/tasks/quickstart stay local. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Claude finished @adrianjm-dotCMS's task in 1m 40s —— View job ReviewRe-reviewed the current head against New IssuesNo new bugs introduced. The fix is at the right layer (one service boundary), the derivation faithfully mirrors Resolved (prior review findings)
Existing
Nice work — the fix is clean, well-scoped to the frontend, and the test coverage now maps onto behavior that can actually go red. The only thing outstanding is one stale relative doc link, which doesn't block.
|
Code review (4 findings):
- The comment on deriveInputsOnEach claimed getByWorkflows' endpoint builds
actionInputs server-side. It does not: POST /schemes/actions/{systemAction}
returns WorkflowHelper#findActions -> List<WorkflowAction>, raw, with the
same omission this PR fixes — despite its @Schema advertising
ResponseEntityWorkflowActionsView. Rewritten to give the two real reasons
separately; getByWorkflows stays out of scope only because edit-content
does not consume it.
- The "NOT re-derive on getByInode" test could not fail: withDerivedActionInputs
short-circuits on any non-empty actionInputs, so a fixture carrying one passed
either way. Fixture now omits the key entirely with commentable: true, which
makes the guard bite. Verified by mutation: adding map(withDerivedActionInputs)
to getByInode turns it red.
- Dropped the misleading `action?.` in withDerivedActionInputs — deriveActionInputs
dereferences the action anyway, so the optional chain only moved the TypeError
one line down while reading as if null were handled.
- The gating tests replaced the factory-level DotWizardService.open mock return
value; jest.clearAllMocks() clears calls but not implementations, so the
never-emitting Subject leaked to any later test. Restored in afterEach.
Type errors (found in review, missed by nx test and nx lint — jest transpiles
with isolatedModules and eslint does not run TS control-flow analysis):
- 5x TS2454 in the service spec: `let result: T[]` assigned inside a subscribe.
Initialised to [] rather than using a definite-assignment assertion, so the
test still fails loudly if the observable never emits.
- 2x TS2339 in the component spec: fireWorkflowAction is an rxMethod, so its
parameter is a union of (value | Observable | factory) and `.data` does not
exist on it. Added a typed firedPayload() reader.
Baseline confirmed unchanged: the component spec had 7 pre-existing tsc errors
before this work and has the same 7 now.
New tests: push publish on content with no inode routes through the environment
check — no fire without environments, wizard with them. This behavior change is
intentional. Deriving pushPublish keeps the helper a faithful mirror of
createActionInputViews and keeps new content consistent with saved content,
which already receives pushPublish via getByInode. The previous behavior only
looked like a success: PushPublishActionlet logs "There are no valid
environments to sent to" and returns when whereToSend is absent, so the action
saved while its push publish silently did nothing.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
| // gates its input wizard on `actionInputs.length`, so without derivation a commentable | ||
| // action fires with no dialog. Fixtures below deliberately omit the key, the way the real | ||
| // payload does. | ||
| const rawCommentableAction = { |
There was a problem hiding this comment.
Hand-rolling a DotCMSWorkflowAction here goes against the frontend testing standard: domain fixtures belong in @dotcms/utils-testing as createFake* helpers (same pattern as createFakeContentlet / createFakeSite), not as inline objects in the spec.
There is already mockWorkflowsActions in dot-workflows-actions.mock.ts, but it always includes actionInputs, so it cannot stand in for this suite — these tests need the key omitted, matching WorkflowDefaultActionView. Reusing that constant would hide the bug this describe is covering.
Please add something like createFakeWorkflowAction(overrides?: Partial<DotCMSWorkflowAction>) in @dotcms/utils-testing and use it here (and in dot-workflows-actions.utils.spec.ts, which currently duplicates the same shape as buildRawAction). Keep actionInputs out of the defaults so callers can still represent the raw REST payload this PR is fixing.
There was a problem hiding this comment.
Done. createFakeWorkflowAction(overrides?) in @dotcms/utils-testing, following the createFakeContentlet pattern, with actionInputs kept out of the defaults as you asked. Used in both specs, so buildRawAction and the duplicated literal are gone. b8e0d8c0f7
nicobytes
left a comment
There was a problem hiding this comment.
Review: correctness, quality, simplification, Angular standards
Reviewed locally against 110d64cd46 (branch HEAD matches the PR head), with the Java side verified in the source rather than from the description.
Verdict: approve-with-comments — nothing here blocks. The diagnosis is right, the fix is at the correct layer (one service boundary instead of the three call sites), and the "why not the backend" argument holds up. The three Required items are test hygiene and code-vs-doc consistency, not correctness of the fix.
Verified independently, so treat these as settled
- The Java mirror is faithful.
deriveActionInputsmatchesWorkflowResource#createActionInputViews(WorkflowResource.java:902-928) line for line, including rule order and themoveable-with-path exclusion. The flags it reads are all really serialized:isAssignable()/isCommentable()carry no@JsonIgnore, and thehas*Actionlet()getters carry explicit@JsonProperty(Jackson would not auto-detect ahasX()getter otherwise). - The blast radius is contained. Only three call sites consume
getDefaultActions/getWorkFlowActions, all insidelibs/edit-content. No other portlet changes behavior.getByWorkflows, left underived, has one consumer —dot-workflows-actions-selector-field.service— which selects actions rather than firing them, so the omission is genuinely inert there. - No silent no-op path. I checked whether a derived array could make
setWizardInput()returnnull, which would haveopenWizardcalldotWizardService.open(null)and hang with no dialog and no error. It cannot: every id the derivation emits maps to a wizard step. Worth noting the component'sopenWizardstill lacks theif (wizardInput)guard thatDotWorkflowEventHandlerService.openWizardhas, so this is a near miss rather than a designed-out one. - Tests pass.
pnpm nx test data-access --testPathPatterns=dot-workflows-actions→ 2 suites, 25/25 green.
One cross-cutting note
The diff carries roughly 90 lines of comment for 120 lines of code, and three of the findings below (2, 3, 6) are places where a comment and the thing it describes have already diverged before merge. The comments are unusually thoughtful, which is why this is worth saying out loud rather than only case by case: a comment that restates a nearby invariant, or describes code in another file, is a second source of truth that nothing tests. The ones that carry real intent — the Java mirror pointer, the #36883 framing, why getByInode is left alone — are the ones worth keeping.
Findings are labelled Required / Consider / Nit inline; 3 Required, 4 Consider, 6 Nit. I have left the formal approval to you rather than stamping it myself.
🤖 Generated with Claude Code
| // Subjects, and `jest.clearAllMocks()` clears call data but NOT implementations — | ||
| // so without this restore the last Subject would leak into any later test that | ||
| // expects `open()` to emit, breaking it by declaration order alone. | ||
| afterEach(() => { |
There was a problem hiding this comment.
Required — this afterEach restores one mock out of four.
The comment diagnoses the problem exactly right: jest.clearAllMocks() (line 190) clears call data but not implementations, and these are factory-level mockProviders shared by the whole file. But the restore only covers DotWizardService.open. Three more overrides introduced in this block are never restored:
processWorkflowPayload—mockImplementation(line 763)containsPushPublish→true(lines 832, 848)checkPublishEnvironments→of(false)/of(true)(lines 833, 849)
DotWorkflowEventHandlerService is a factory-level mockProvider too, so from line 832 onward containsPushPublish returns true for the remaining ~1000 lines of this spec. It is latent today only because nothing below describe('Preview Button') fires a workflow action — the next test added there inherits it silently, and will fail for a reason that has nothing to do with its own subject.
Cheaper and order-independent than compensating per-mock: set the default implementations in the file's root beforeEach, or add restoreMocks: true to the Jest config (alternatively jest.restoreAllMocks() in the root afterEach), and drop this afterEach entirely.
There was a problem hiding this comment.
Confirmed, and worse than it looks: this is the same bug I fixed last round, reintroduced by the new tests.
Went with your first option rather than compensating per mock: the defaults for all four (open, containsPushPublish, checkPublishEnvironments, processWorkflowPayload) are now set in the root beforeEach, and the partial afterEach is gone. Order-independent, and it doesn't depend on the next block remembering.
Ruled out restoreMocks: true — mockProvider creates jest.fn()s, not spies, so restoreAllMocks wouldn't touch their implementations.
| // No optional chaining on `action` itself: `deriveActionInputs` dereferences it anyway, so a | ||
| // `?.` here would only move the TypeError one line down while reading as if it were handled. | ||
| // A missing action is a malformed payload — let it fail loudly rather than half-guard it. | ||
| if (action.actionInputs?.length) { |
There was a problem hiding this comment.
Required — the gate and its doc comment disagree.
The doc above says "An array the server already sent is left untouched", but action.actionInputs?.length is truthiness on the length: a server-provided empty array is re-derived. The service spec already documents the gap in its own comment ("short-circuits on any NON-EMPTY array"), which is a good sign that the code, not the comment, is the odd one out.
It is harmless today, because both endpoints omit the key entirely. But the stated purpose of this guard is to surface a backend regression rather than mask it, and the one case it fails to guard is exactly the regression shape you would most want to see: an endpoint that starts emitting WorkflowActionView with a legitimately empty actionInputs.
if (Array.isArray(action.actionInputs)) {
return action;
}That makes the code match the comment, keeps the "never undefined" guarantee intact, and leaves every current test green — the getByInode assertion still holds, since that fixture omits the key rather than sending [].
There was a problem hiding this comment.
Right. Array.isArray(action.actionInputs), exactly as proposed. Added an explicit test for the server-sent [] — the one case the guard claimed to cover and didn't. Also updated the guarantees table in contracts/.
| ## Sync obligation | ||
|
|
||
| If `createActionInputViews` gains an input type in Java, this helper and its spec must be updated | ||
| in the same PR. Each side carries a comment naming the other. This duplication is the accepted |
There was a problem hiding this comment.
Required — the sync obligation only exists on one side.
"Each side carries a comment naming the other" is not true as of this PR: there is no Java change in the diff (0 .java files), and WorkflowResource.java contains no reference to dot-workflows-actions.utils.ts or to deriveActionInputs. So a Java dev adding a fifth input type to createActionInputViews has nothing pointing them at the TypeScript mirror — which is the single failure mode this whole section exists to prevent.
Either option is fine:
- Add the one-line comment above
createActionInputViewsnaming the TS mirror. Note this puts a.javafile in the diff — worth confirming against ADR-0013 whether a comment-only Java change still keeps the PR on the fast CI path. - Drop the claim from this doc and file a follow-up issue, so the drift risk has a ticket instead of a sentence.
For the record, I verified the mirror itself is faithful line-for-line against WorkflowResource.java:902-928, including rule order and the moveable path exclusion.
There was a problem hiding this comment.
Confirmed: 0 .java files in the diff, the claim was false.
Took your option 2, for the reason you flagged yourself: a comment-only Java change puts a .java file in the diff and, per ADR-0013, moves the PR off the fast CI path for no behavioral gain. The doc now states the actual situation — the pointer exists only TS→Java — and why it isn't added here. The follow-up issue is still owed; I'll open it if you agree.
|
|
||
| // Has a move actionlet but the path is empty — a preset path means there is nothing to ask | ||
| // the author for. | ||
| if (action.hasMoveActionletActionlet && !action.hasMoveActionletHasPathActionlet) { |
There was a problem hiding this comment.
Consider — moveable is a second behavior change, and it is not documented.
The description calls out pushPublish as the intentional behavior change, but the derivation emits moveable too, and that is a second one. A first-step action with a Move actionlet and no preset path previously fired directly on unsaved content; it now flows through mergeCommentAndAssign into a commentAndAssign step and opens the wizard asking the author for a path.
That is the right outcome — it is parity with saved content, which already gets moveable from getByInode. I also checked the failure mode it could have had: setWizardInput() returning null would have made the action a silent no-op, since openWizard in the component passes the result straight to dotWizardService.open() without the if (wizardInput) guard that DotWorkflowEventHandlerService.openWizard has. It does not happen — isValidActionInput includes MOVEABLE, so a moveable-only action still yields one step — but that is a narrow miss worth knowing about.
Two asks: mention it in the description next to the Push Publish note so QA exercises it, and add a test. The closest existing one asserts the derivation, not that a moveable-only action reaches openWizard.
There was a problem hiding this comment.
Both asks done: a test that a moveable-only action reaches openWizard, and the PR section went from "Push Publish" to "Intentional behavior changes", with a paragraph on moveable noting it has no environment gate and therefore cannot block a save.
Thanks for the setWizardInput() → null detail — I wasn't aware of that asymmetry between the component's openWizard and the one in DotWorkflowEventHandlerService.
| map((x) => x?.entity), | ||
| map((res) => res || []) | ||
| map((res) => res || []), | ||
| map(deriveInputsOnEach) |
There was a problem hiding this comment.
Consider — three maps where one says the same thing.
.pipe(map((res) => deriveInputsOnEach(res?.entity ?? [])));Same behavior, one operator instead of three, two fewer intermediate emissions per call, and ?? [] reads more directly than map((res) => res || []). Applies identically to getWorkFlowActions (line 152).
There was a problem hiding this comment.
Applied as suggested, both methods.
| // No optional chaining on `action` itself: `deriveActionInputs` dereferences it anyway, so a | ||
| // `?.` here would only move the TypeError one line down while reading as if it were handled. | ||
| // A missing action is a malformed payload — let it fail loudly rather than half-guard it. |
There was a problem hiding this comment.
Nit — three lines explaining why an optional chain isn't there. The deriveActionInputs body one line down already makes the requirement obvious, and a comment about absent code is pure carrying cost: it has to be re-read by everyone and re-verified by anyone who touches the guard.
| // `DotWorkflowsActionsService` now guarantees `actionInputs` is populated on every | ||
| // action it returns, including for content with no inode (#36883) — the `= []` is a | ||
| // belt-and-braces default, not the contract. |
There was a problem hiding this comment.
Nit — three lines to say a default is belt-and-braces. Two things make it weaker than it looks: actionInputs is a required (non-optional) field on DotCMSWorkflowAction, so = [] was never the contract to begin with; and "the service now guarantees" reads as stale the day a fourth caller appears that does not go through DotWorkflowsActionsService. One line, or none.
There was a problem hiding this comment.
Removed entirely rather than shortened. Your point that actionInputs is non-optional settles it: the default was never the contract, so the comment was defending something that didn't exist.
| }); | ||
|
|
||
| describe('actionInputs derivation (#36883)', () => { | ||
| // The default/initial-action endpoints return `WorkflowDefaultActionView`, which wraps the |
There was a problem hiding this comment.
Nit — rawCommentableAction is the same 13-field literal as buildRawAction in dot-workflows-actions.utils.spec.ts, in the same folder. Export the builder from one place — or from @dotcms/utils-testing, where the other workflow mocks already live — so the two specs cannot drift apart on what "the raw payload" looks like.
There was a problem hiding this comment.
Solved by createFakeWorkflowAction in @dotcms/utils-testing. One definition of "the raw payload", shared by both specs.
| order: 0, | ||
| roleHierarchyForAssign: false, | ||
| schemeId: 'scheme-id', | ||
| showOn: ['NEW', 'EDITING'] |
There was a problem hiding this comment.
Nit — (action: object) accepts anything and checks no keys. Partial<DotCMSWorkflowAction> types it properly and would catch a typo in a fixture at compile time instead of in a confusing assertion failure.
There was a problem hiding this comment.
Now Partial<DotCMSWorkflowAction>.
| @@ -0,0 +1,67 @@ | |||
| # Internal Contract: `deriveActionInputs()` | |||
|
|
|||
| **Plan**: [plan.md](./plan.md) · **Data model**: [data-model.md](./data-model.md) | |||
There was a problem hiding this comment.
Nit — [plan.md](./plan.md) is broken twice over: plan.md is not committed, and from contracts/ this relative path resolves to contracts/plan.md rather than the sibling directory. Same line in data-model.md:3. Either drop the links or commit the plan.
There was a problem hiding this comment.
Verified broken twice over, as you said: plan.md is gitignored (.gitignore:229) and from contracts/ the path resolved to contracts/plan.md. Replaced with links to spec.md and data-model.md, which are tracked.
…ivation (#36883) Required: - The gating tests restored one factory-level mock out of four, leaving containsPushPublish stuck on true for the rest of the spec file. Moved the defaults (open, containsPushPublish, checkPublishEnvironments, processWorkflowPayload) into the root beforeEach so every test starts from the same state regardless of declaration order, and dropped the partial afterEach. - withDerivedActionInputs gated on `actionInputs?.length`, which re-derived a server-sent empty array — the opposite of what its doc claimed and precisely the backend regression the guard exists to surface. Now Array.isArray, with a test for the empty case. - contracts/derive-action-inputs.md claimed "each side carries a comment naming the other". There is no Java change in this PR, so the drift risk is one-way. Claim replaced with the actual state and why no .java comment is added here: it would move the PR off the frontend-only CI path (ADR-0013) for no behavioral gain. Worth a follow-up issue. Considered: - moveable is a second intentional behavior change alongside pushPublish, and had no test. Added one, and it is called out in the PR description. - getDefaultActions/getWorkFlowActions collapsed from three map operators to one: map((res) => deriveInputsOnEach(res?.entity ?? [])). - The 15-line comment above deriveInputsOnEach mostly described endpoints 60-100 lines away, duplicating the table in contracts/. Trimmed, and the helper moved to dot-workflows-actions.utils.ts where that rationale lives — the service goes back to HTTP plumbing. - Three of the four "guarantees" tests could not fail: buildRawAction never set the actionlet flags so both deletes were no-ops, and the idempotence test fed actionInputs to a function that never reads it. Removed, and replaced with a real withDerivedActionInputs spec (derive / preserve / preserve empty / never undefined / no mutation / idempotence) plus one for deriveInputsOnEach. Nits: added createFakeWorkflowAction to @dotcms/utils-testing following the createFakeContentlet pattern, which removes the duplicated 13-field literal across both specs, the Omit<> return type and its 12 casts, and the untyped `(action: object)`. Dropped two comments that described absent code, and fixed the plan.md links (gitignored, and wrong relative path from contracts/). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
All 14 applied in I diverged from the suggestion on two, both explained in their threads: Required 3 is resolved by withdrawing the claim rather than adding the Java comment, for the ADR-0013 reason you raised yourself; and Required 1 is resolved in the root Your cross-cutting note landed. Three of your findings were comments that had already drifted before merge, and one of those I had lengthened last round precisely to fix a different inaccuracy. Four comments deleted; the ones left carry real intent — the Java mirror pointer, the Verification: |
| // Presence, not length: a server-sent empty array is a legitimate "no inputs" answer and must | ||
| // survive, otherwise the one regression this guard exists to surface — an endpoint starting to | ||
| // emit WorkflowActionView with an empty actionInputs — would be silently re-derived over. | ||
| if (Array.isArray(action.actionInputs)) { |
There was a problem hiding this comment.
DotCMSWorkflowAction.actionInputs is declared non-optional in dot-workflow-action.model.ts:26, so from TypeScript's point of view this Array.isArray check can never be false — yet the function exists precisely because initialactions and defaultactions do omit the key.
Scope: the guard here is added by this PR; the model declaration is pre-existing.
That leaves the model type describing a shape those two endpoints never send, and the mismatch is invisible outside this file: anything writing action.actionInputs.length without a guard compiles clean and throws at runtime on a default-actions payload.
Would it be worth making the type carry that distinction rather than leaving it to this one runtime check? A separate WorkflowDefaultActionView type for the un-derived response would express which endpoints omit the field; narrowing this parameter, or marking the field optional on the model, would each work too — the last one surfaces it to every consumer rather than only here.
Or is the intent that the derivation makes the omission un-observable everywhere downstream, so a non-optional field is the accurate description of what consumers now actually receive?
There was a problem hiding this comment.
Confirmed — dot-workflow-action.model.ts:26 declares it non-optional, so from the type system's point of view this guard is unreachable, while at runtime it is the line doing the work.
Your last reading is the intent: after the derivation, "always present" describes what consumers actually receive. But it isn't fully true, and the exception is worth naming — getByWorkflows is not derived. Its pipe is still map((x) => x?.entity), and POST /schemes/actions/{systemAction} returns raw List<WorkflowAction> (WorkflowHelper#findActions), so that path hands back actions with no actionInputs while the type says otherwise. Inert today because its only consumer, dot-workflows-actions-selector-field.service, selects actions rather than firing them — but the type does lie there.
Keeping the guard as-is: the type system believing it unreachable is evidence the type is wrong, not that the check is redundant.
On fixing the type itself — I'd rather not do it here. Marking the field optional surfaces it to every consumer, which is the honest option but forces guards across code this PR doesn't touch; a separate WorkflowDefaultActionView type is cleaner but is a model refactor inside a defect fix. Happy to open a follow-up issue with the two options and the getByWorkflows detail, unless you'd rather it land here.
Spec-Kit PR 2 of 2. Implementation for the spec approved in #37437.
Screen.Recording.2026-09-08.at.12.40.49.PM.mov
What was broken
Firing a workflow action with Allow Comments enabled skipped the comment dialog on its first execution in the new (Angular) Edit Content UI. The contentlet was saved and transitioned, but the author never got to enter the comment — the dialog only appeared on a second execution. Legacy edit mode was correct.
The comment is the audit note recorded in the workflow task history, so this silently degraded the audit trail with no error surfaced.
Root cause
Two REST shapes describe a workflow action, and only one carries
actionInputs[]:actionInputs[]?contentlet/{inode}/actionsWorkflowActionViewinitialactions/contenttype/{id}WorkflowDefaultActionViewdefaultactions/contenttype/{id}WorkflowDefaultActionViewThe editor gates its input wizard on
actionInputs.length, so for content with no inode the array was absent and it took the "no inputs, fire directly" branch. After the first fire the store re-fetches per-inode actions, which do carry the array — hence the dialog on the second click.Verified against a live instance rather than inferred:
The fix — frontend only, no REST change
Every flag the derivation needs is already serialized on the raw
WorkflowAction(none@JsonIgnore), so no backend change is required.dot-workflows-actions.utils.ts(new) —deriveActionInputs(), a TypeScript mirror ofWorkflowResource#createActionInputViews, pluswithDerivedActionInputs()which leaves a server-provided array untouched.dot-workflows-actions.service.ts— applied ingetDefaultActions()andgetWorkFlowActions()only.getByInode()/getByWorkflows()/getBulkActions()are deliberately left alone (commented in place): their endpoints build the array server-side, and deriving over them would hide a future backend regression instead of surfacing it.libs/edit-contenthas no production-code change beyond one clarifying comment — all three affected call sites are fixed transitively by the service-boundary fix.Why not the backend
Emitting
WorkflowActionViewfrom those two endpoints is arguably the cleaner contract, but it changes a published REST response on endpoints reachable by customer integrations, and per ADR-0013 it would flip this PR onto the slow CI path — for an identical behavioral outcome. Recorded as accepted tech debt in the plan's Complexity Tracking: the derivation rule now exists in both Java and TypeScript, and each side carries a comment naming the other.Scope wider than the issue described
The issue describes one reproduction path. There are three, all seeded from the same input-less payload, all closed by this one change:
initializeNewContent(the reported repro)setSelectedWorkflowlocales.featureA fourth path claimed by an early draft of the spec — after a Reset action — was disproved during review and removed.
Intentional behavior changes on unsaved content
deriveActionInputsmirrorscreateActionInputViewsin full, so it emitspushPublishtoo. On content with no inode that routes the action throughcheckPublishEnvironments()instead of firing directly — so with no publish environment configured, a Push Publish action that previously "saved" now shows the add-an-environment error and does not run.Deliberate: the previous save only looked like a success. It fired with no
whereToSend, andPushPublishActionletlogs"There are no valid environments to sent to"and returns (PushPublishActionlet.java:145-149) — content saved, push publish silently did nothing. Deriving it also keeps new content consistent with saved content, which already getspushPublishfromgetByInode.Narrow reach: needs an admin to put a Push Publish sub-action in the workflow's first step; none of the 7 content types checked on a demo instance have one.
moveableis the second one. A Move action with no preset path also fired directly on unsaved content before; it now folds into acommentAndAssignstep and asks the author for a destination. Same reasoning — parity with saved content, which already getsmoveablefromgetByInode— and no environment gate, so it cannot block a save. Worth exercising in QA alongside Push Publish.assignableis the third. Same mechanism: an action with Assign-to on unsaved content now presents the role picker on the first click instead of firing straight through. Same reasoning again — parity with saved content, which already getsassignablefromgetByInode— and no gate that can block a save. Already covered by a pre-existing test (dot-edit-content-form.component.spec.ts:691).Tests
25 tests across the two touched specs plus one new spec file.
deriveActionInputsunit spec (new)[]neverundefined, preserve server-provided, and not re-derived ongetByInodeactionInputssurvives the store's reshape on the reported repro's pathRed confirmed before implementing (Constitution Principle V):
Expected: [{"body":{},"id":"commentable"}] / Received: undefined.A note on what could and could not go Red
Only the utils and service specs can actually fail for this bug. The component's branch logic was never wrong —
actionInputsarrives as an argument — and it was already covered. The store specs mockDotWorkflowsActionsServiceentirely, so they never see the real derivation. Rather than write always-green tests and call them regression guards, the component tests were pointed at the genuinely-uncovered AC-002 gating behavior, and two of the three planned store tests were dropped as redundant copies of the same assertion. Signed off by the developer at the task gate.Verification
pnpm nx test data-access— 86 suites, 870 passedpnpm nx test edit-content— 116 suites, 2346 passedpnpm nx run-many -t lint -p data-access,edit-content— 0 errors (4 warnings, all pre-existing)pnpm nx format:check— cleannpx tsc -p tsconfig.spec.json --noEmit— 0 errors in the touched files (pre-existing baseline unchanged)core-web/;openapi.yamluntouchedNot run, deliberately: backend integration and Postman. No Java changes, and per ADR-0013 a
core-web/**-only PR skips both in the merge queue — confirmed in practice, neither was triggered.Checklist
edit-content/workflows/workflow-actions-dialog.spec.ts(the regression spec for [TASK] Show workflow dialogs for comments and user assignment in New Edit Contentlet mode #34347) covers this dialog but always starts from an existing inode, so it never exercises the path this bug lived on. The new-content variant is blocked on fixture behavior: an action created on the workflow's first step does not surface inGET /initialactions/contenttype/{id}. Worth its own investigation.Additional Info
Resolves #36883. Spec: #37437.
🤖 Generated with Claude Code
This PR fixes: #36883