Skip to content

feat(experiments): change the page of a draft that has variants - #37306

Merged
oidacra merged 11 commits into
mainfrom
issue-37003-change-page-of-a-draft-with-variants
Sep 1, 2026
Merged

feat(experiments): change the page of a draft that has variants#37306
oidacra merged 11 commits into
mainfrom
issue-37003-change-page-of-a-draft-with-variants

Conversation

@oidacra

@oidacra oidacra commented Aug 31, 2026

Copy link
Copy Markdown
Member

Proposed Changes

Follow-up on the Configure screen (Screen 2) of the Experiments portlet: changing the Page of a draft that already has Variants.

CleanShot.2026-08-31.at.15.56.39.mp4
  • Change Page replaces Clear. With a page in place the Page card offered an X with a tooltip; it now offers a Change Page button. It is disabled only once the experiment is past draft, and then it explains itself with the same locked copy every other field uses.
  • Happy path untouched. No non-control Variant — or no experiment yet, on /experiments/new — goes straight to the page picker, with no confirmation in the way.
  • A confirmation when Variants would be deleted. ExperimentsAPIImpl.save() refuses a page change while a non-control Variant holds a copy of the current page, so the change deletes them first. A dialog names the page and every Variant by name, states that the action cannot be undone, and puts the deletion behind the primary Delete Variants And Change Page, against Keep Current Page. Only when they are all gone does the picker open; Cancel, the X and ESC leave the experiment exactly as it was — and none of the three is available while a deletion is on the wire, since the run does not stop with the dialog.
  • The deletions run one after another (concatMap). Each has the backend recompute the traffic proportion over what is left, so only the answer to the last call describes the experiment afterwards — firing them in parallel would leave which answer is current up to arrival order.
  • A rejection halfway keeps what did go through. The failure carries the experiment the last successful deletion answered with, so the Variants card stops offering weights for rows that no longer exist. It is reported unobtrusively — a toast, not an alert over the dialog, which would bury the retry — and the dialog stays open with the failure stated inline beside its own buttons.

Removed along with the X: the pageCleared event, and the two tooltip keys that became unreachable once a non-draft experiment reads with the generic locked copy. VARIANT_COLORS moved to shared/constants.ts, since the dialog draws the same Variants in the same colours as the card.

Checklist

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

Additional Info

How the pieces are split. The confirmation is a view: it renders what it is handed and reports the go-ahead as pageChangeConfirmed. The wait and the failure reach it as store signals through PrimeNG's inputValues — signals rather than values, because inputValues is applied once via setInput when the dialog is created, so a boolean would freeze at whatever it was on open. The Page card owns both the store and the DynamicDialogRef, so it is what closes the dialog on deleteVariantsSucceeded; the dialog is created outside the card's injector and cannot reach the store at all. Both dialogs go through DialogService, so the handover stays one onClose chain.

deleteVariantsSucceeded / deleteVariantsFailed are deliberately their own pair rather than a plural removeVariant*: the common case deletes exactly one Variant, so the payload cannot tell the two flows apart, and they differ in error surfacing (toast vs. the standard alert) and in state. Same precedent as abortSucceeded vs cancelScheduleSucceeded in this store — one call, two events, because the consequence differs.

Two behaviours worth exercising by hand. The deletion is irreversible and happens before the picker: confirming and then cancelling the picker leaves the Variants deleted and the page unchanged. And the new page is persisted by Save Draft, not by picking it — pageId travels in the footer's PATCH, as it already did.

Verification. 935/935 portlet tests, nx lint clean, nx format:check clean, nx affected -t test green across the 4 affected projects.

⚠️ Open question for the reviewer: this diverges from the signed-off spec of #37176

specs/37176-draft-experiment-page-change/spec.md — committed on main and reviewed twice (7a280d6698, 54d378bcb8) — specifies the opposite affordance for this exact situation:

  • FR-009: "When a Draft experiment has at least one non-control variant, the page selection control MUST be disabled and MUST explain: 'This experiment already has variants. Delete them to change the page.'"
  • User Story 3 ("Variants explain why the page is locked", P2), whose acceptance test is "assert the control is disabled and carries exactly that explanation".
  • The spec also declares that sentence as fixed copy backed by a language key — the very key this PR deletes (experiments.configure.page.select.has-variants.tooltip).

This PR ships cascade-delete-then-proceed instead of disable-and-explain. It is a deliberate product decision, not an oversight, and the backend rule is untouched: ExperimentsAPIImpl.save() still requires DRAFT + control-is-the-only-variant (1fbdeb837d), which is why the variants are deleted first rather than the rule being relaxed.

What that means for this review:

  • Nothing in specs/37176-draft-experiment-page-change/ is updated by this PR, so the committed spec now contradicts what ships. Read on its own, it will tell the next person the picker is disabled.
  • Per CLAUDE.md ("If the spec changes after sign-off, get it re-approved"), the call belongs to whoever owns that spec: either FR-008/009/010 + User Story 3 get rewritten around the new flow and re-approved, or this flow goes back to disable-and-explain.

I left the spec untouched on purpose rather than rewriting a signed-off document unilaterally. Flagging it here so it is a decision rather than a discovery.

Review of this branch

A multi-agent review of the first commit surfaced five defects on the destructive path, all fixed in 42b120f581:

  1. X/ESC could dismiss the confirmation mid-deletion. Only the Cancel button was gated; PrimeNG's chrome calls close() directly. The run does not stop with the dialog, so a half-finished cascade could complete with nothing reporting it, and the card kept a reference it could no longer close — making the eventual success a no-op, so the picker never opened. closable/closeOnEscape are now getters over deletingVariants, and the success handler opens the picker whether or not a dialog is left to close.
  2. The dialog's variant list was frozen at open time while deleting/failed were signals — for exactly the reason the list needed to be one. After a partial failure it went on naming, and offering to delete, variants that were already gone.
  3. The failure copy implied nothing had happened ("so the Page has not changed") when some variants may be permanently deleted.
  4. The store had no re-entrancy guard. The dialog guarded its own button, but every dispatch reached the store, and switchMap would cancel a run whose deletions had partly landed. deletingVariants now closes the door, raised by deleteVariantsRequested as the first call leaves — the same contract create$/start$ keep.
  5. A confirmation with nothing left to delete was dropped silently, leaving the dialog waiting on a run that never went out. It is answered as success now (reachable: the variants can go from another tab between opening the dialog and confirming it).

Not changed, and worth a second opinion: canChangePage() requires variants.length > 0, so a draft with zero variants reads as immutable. Unreachable today (the creation POST always makes the control), and it mirrors a server-side rule, so I left it alone rather than widening the change.

Refs #37003

This PR fixes: #37003

The Page card offered Clear (an X with a tooltip) once a page was chosen,
and disabled Select outright on a draft that already had variants — the
server refuses the change while a non-control variant holds a copy of the
page. Now the card offers Change Page, and the variants are a step on the
way rather than a wall:

- No non-control variant, or no experiment yet: straight to the picker.
- Otherwise a confirmation names the page and every variant the change
  would delete, and deletes them behind a red Delete Variants And Change
  Page. Only when they are all gone does the picker open.

The deletions run one after another (concatMap): each has the backend
recompute the traffic proportion over what is left, so only the answer to
the last one describes the experiment afterwards. A rejection halfway
keeps the answer the run did get, so the Variants card stops offering
weights for rows that no longer exist, and is reported unobtrusively — a
toast, not an alert over the open dialog, which would bury the retry.

The confirmation is a view: it renders what it is handed and reports the
go-ahead. The wait and the failure reach it as store signals through
inputValues (applied once via setInput, hence signals rather than values),
and the Page card — which owns both the store and the dialog reference —
is what closes it on deleteVariantsSucceeded. Both dialogs go through
DialogService, so the handover stays one onClose chain.

Gone with the X: the pageCleared event, and the two tooltip keys that were
unreachable once a non-draft experiment reads with the same locked copy as
every other field. VARIANT_COLORS moved to shared/constants, since the
dialog draws the same variants in the same colours as the card.
@claude

claude Bot commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

Claude finished @oidacra's task in 3m 41s —— View job


Code Review

Reviewed the full diff against origin/main. The destructive cascade path (store handler, reducers, re-entrancy guard, partial-failure folding) is carefully built and well-covered by the new store specs. Two things stand out.

New Issues

  • 🟡 Medium: dotCMS/src/main/webapp/WEB-INF/messages/Language.properties:6137-6138 — The warning copy conditions the deletion on a change that the flow does not actually make conditional. experiments.configure.page.change.warning.one/many read "If you change the Page, N Variant(s)… will be deleted", but confirming deletes the variants immediately and unconditionally, before the picker opens; cancelling the picker afterward leaves the variants permanently gone and the page unchanged (dot-experiments-configure-page.component.ts:341 openPageSelector runs only after deleteVariantsSucceeded). The text promises the opposite of what happens. This is the same concern @adrianjm-dotCMS raised (still open); either invert the flow (picker → confirm → delete) or reword the copy to say the deletion happens now regardless of the picker. Fix this →

  • 🟡 Medium: core-web/libs/portlets/dot-experiments/portlet/src/lib/dot-experiments-configure/components/dot-experiments-configure-page/dot-experiments-configure-page.component.ts:341 — Possible dead-click regression from the picker swap. This PR replaces DotBrowserSelectorComponent (which fell back to System Host via currentSiteId()) with AngularAssetPickerLauncher, which requires a resolved DotSite. Both onChangePage() (line 320) and openPageSelector() (line 342) return silently when siteDetails() is null, yet $isPageActionDisabled does not account for site. So if a user clicks Select/Change Page before the site has resolved, the enabled button does nothing with no feedback — the old browser had a fallback here.

    • Assumption: GlobalStore.siteDetails() can briefly be null on first render of /experiments/new.
    • What to verify: whether the site is guaranteed resolved before this card is interactive (resolver/guard). If so, this is unreachable and can be dismissed; if not, consider folding !siteDetails() into the disabled state so the button reflects it. Note this picker migration is not mentioned in the PR description — worth calling out explicitly for reviewers.

Resolved

  • dot-experiments-configure-page.component.ts:232 — dead-click on a zero-variant draft: addressed in ad68e852 by disabling the button when !$canChangePage() && !$deletableVariants().length.
  • dot-experiments-configure-page.component.ts:312 — picker opening for runs this card never started: #awaitingPageChange guard added in ad68e852.
  • ✅ Clean removal of pageCleared, SELECT_PAGE_DIALOG_WIDTH/MAX_WIDTH, and the two tooltip keys — no dangling references remain in the portlet.

Everything else (concatMap ordering, deletingVariants re-entrancy guard mirroring create$/start$, deleteVariantsFailed folding in the last successful experiment, signals-over-inputValues for the live variant list) checks out and is tested.

Note on the flagged spec divergence (#37176 FR-009): that's a product/ownership call, not a code defect — correctly escalated in the PR body rather than resolved unilaterally.

· branch issue-37003-change-page-of-a-draft-with-variants

@github-actions github-actions Bot added the Area : Frontend PR changes Angular/TypeScript frontend code label Aug 31, 2026
Follow-up on the review of this branch. Five findings, all on the
destructive path:

The confirmation could be dismissed with X or ESC while the deletions
were still running. Only the Cancel button was gated on the in-flight
state; PrimeNG's own chrome calls close() directly. The run does not stop
with the dialog, so a half-finished cascade could finish with nothing on
screen reporting it, and the card was left holding a reference it could
no longer close — which made the eventual success a no-op, so the picker
never opened. Both halves are fixed: `closable`/`closeOnEscape` are now
getters over `deletingVariants` (PrimeNG re-reads them on every change
detection), and the success handler opens the picker whether or not a
dialog is left to close.

The dialog's variant list was a snapshot taken at open time, while
`deleting` and `failed` were signals — for exactly the reason the list
needed to be one too. After a run refused halfway it went on naming, and
offering to delete, variants that were already gone. It is a signal now.

The failure copy said the Variants could not be deleted "so the Page has
not changed", which reads as nothing happened when some may be
permanently gone. It now says which ones are left.

The store had no re-entrancy guard: the dialog guarded its own button,
but the store took every dispatch, and `switchMap` would cancel a run
whose deletions had partly landed to start another from a list that had
not caught up. `deletingVariants` now closes the door, raised by a new
`deleteVariantsRequested` the handler emits as the first call leaves —
the contract `create$` and `start$` already keep, and the only one that
works, since a reducer on the intent runs before the handler and its
guard would refuse the first press.

A confirmation with nothing left to delete is answered as success rather
than dropped. It is reachable — the variants can go from another tab
between opening the dialog and confirming it — and the page is free
either way, so dropping it left the dialog waiting on a run that never
went out.

Also corrects the handler's comment, which claimed the event "cannot
arrive" without variants while the guard and a test right beside it
exist because it can.

931 tests.
…orm does

No icon. The warning triangle led the dialog with decoration where the
other confirmations on the platform lead with the sentence, so it is gone
and the paragraph is the first thing read.

The header asks the question — "Change Page?" — and the paragraph answers
it: what changing the page costs, and that it cannot be undone, in one
block instead of a grey line under the list. It names the page by title,
in quotes, rather than by path.

Neither button says nothing any more: "Keep Current Page" against "Delete
Variants And Change Page", so both choices are legible without reading
the paragraph again.

Still not a `p-confirmDialog`, which is the usual vehicle for this: that
one closes on accept and has nowhere to put a wait or a failure, and this
operation has both — the button spins while the deletions run and the
dialog stays open, with the failure inline, for the retry.

Copy note: the mock this follows reads "If you delete <page> all its
Variants will be deleted" with a "Delete Page and Variants" button. Both
say the page is deleted, which it is not — it is repointed — so the shape
is adopted and the wording corrected.

933 tests.
Ports the migration done in the results-screen worktree onto this branch,
where the Page card now lives.

`openPageSelector()` opened `DotBrowserSelectorComponent` with a hand-built
dialog config. It now goes through `AngularAssetPickerLauncher`, in browse
mode narrowed to pages by `allowedBaseTypes`, with the browse flags the
picker actually understands. The launcher is stateless and carries no
`providedIn`, so it is provided beside `DialogService`.

The site is no longer an id: the picker browses a `DotSite` and, unlike the
browser it replaces, has no System Host to fall back on. With none resolved
the press is a no-op rather than a dialog opened against nothing.

`SELECT_PAGE_BROWSER_PARAMS` loses `showPages`/`showFiles`/`showDotAssets`
— what the list may contain is `allowedBaseTypes`' job now — and the two
dialog-width constants go with the config that used them. `showWorking`
stays: omitting it would read as the same thing but `false` is the only
value that narrows, and the old screen listed drafts.

Both entry points still funnel through `openPageSelector()`, so the
Change Page confirmation reaches the new picker exactly as the happy path
does.

Requires the merge of main in the preceding commit: `allowedBaseTypes` and
`browse` do not exist on this branch's fork point.

934 tests.
…ft-with-variants' into issue-37003-change-page-of-a-draft-with-variants
The site was checked where the picker opens. Between the Change Page press
and that point sits the deletion of every non-control variant, so with no
site resolved the flow ran the whole irreversible cascade and then found
nothing to open the picker against: the user paid the entire cost and got
none of the screen they asked for.

Checked at the press instead, where nothing has been spent yet. The
no-site press stays the same bare no-op it already was on the direct path
— the decision not to disable the button and explain itself is untouched;
it just now applies before the destructive step rather than after it.

935 tests.
It was `severity="danger"`. The design it follows draws it as the primary
button, and so does every other confirmation on the platform — the repo's
own way of marking the primary action is to omit `severity` altogether, as
the Add Variant dialog's submit does.

The weight is carried by the labels, which say what each choice does:
"Keep Current Page" against "Delete Variants And Change Page".
adrianjm-dotCMS
adrianjm-dotCMS previously approved these changes Sep 1, 2026
Two of the three review threads.

**A dead click becomes a disabled button.** `canChangePage()` requires at
least one variant, `$isPageActionDisabled` only asked about the lock, and
the early return swallowed the press — so a draft whose variants array is
empty offered a button that did nothing and said nothing.

The reviewer preferred dropping the `variants.length > 0` clause, on the
grounds that a draft with no variants has nothing to orphan. The server
disagrees: `hasOnlyTheControlVariant` is `variants.size() == 1 &&
first().id() == DEFAULT`, so zero variants is refused there too. Dropping
the clause would have the card send a PATCH the API rejects. Disabled it
is, which keeps both sides saying the same thing.

**The picker no longer opens for a run this card did not start.**
`deleteVariantsSucceeded` is a global event and the card's answer to it is
to open a dialog. Only this flow can produce it today, so `#awaitingPageChange`
changes no behaviour — it writes the invariant down instead of leaving it
resting on there being one producer.

The test that covered "the answer arrives with no confirmation left" now
asserts the opposite for a run that was never started, and two more cover
a second answer and a cancelled confirmation. The case it used to protect —
dismissed mid-run — is unreachable since the dialog withdrew `closable`
and `closeOnEscape` while deleting.

938 tests.
@oidacra
oidacra added this pull request to the merge queue Sep 1, 2026
Merged via the queue into main with commit a58aeb4 Sep 1, 2026
49 of 50 checks passed
@oidacra
oidacra deleted the issue-37003-change-page-of-a-draft-with-variants branch September 1, 2026 17:47
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

AI: Safe To Rollback Area : Frontend PR changes Angular/TypeScript frontend code

Projects

Status: No status

Development

Successfully merging this pull request may close these issues.

Experiments Portlet — Screen 2: Create/Update (/experiments/new + /:id/configuration)

2 participants