Skip to content

feat(experiments): the page's experiments as a panel beside the canvas (#37478) - #37485

Open
oidacra wants to merge 6 commits into
mainfrom
issue-37478-uve-experiments-panel
Open

feat(experiments): the page's experiments as a panel beside the canvas (#37478)#37485
oidacra wants to merge 6 commits into
mainfrom
issue-37478-uve-experiments-panel

Conversation

@oidacra

@oidacra oidacra commented Sep 9, 2026

Copy link
Copy Markdown
Member

Closes #37478

With FEATURE_FLAG_EXPERIMENTS_PORTLET on, the Universal Visual Editor's Experiments entry point currently ejects the editor: the science nav item navigates to the portlet, the iframe is torn down, and the editor gets back to the page through the breadcrumb. This keeps the editor on the page — the experiments for the page in hand render in a panel beside the canvas, behind the same flag. No new switch, no second rollout.

It also closes what #37005 (flagged UVE entry point) left half-done. Its FR-021c asked for the page filter to be "visible and clearable"; the filter is applied but has no affordance to clear it, and clearing it would be a dead end — the portlet is opt-in, so on a stock instance the admin menu carries no Experiments entry to return to. In a page-scoped panel there is no filter to clear: the panel is the page's scope by construction, and "clearable" becomes an explicit link out to the full portlet.

Proposed Changes

The flow, end to end

  • The Experiments nav item becomes an action, not a destination — no navigation, no route, no address change. A panel opens beside the canvas and the page stays rendered.
  • A page with no experiments opens straight on creation. An empty list is not a resting state — on an untested page there is exactly one useful next step.
  • A page with experiments opens on that page's list, and the editor picks one to configure.
  • The page is fixed in creation and configuration. No page picker, no editable page field, no change-page flow — the editor is standing on the page, so a control offering to move the experiment elsewhere contradicts the surface it renders on.
  • Preview / Edit Content on a variant leaves for the page as that variant, with a banner naming the variant and the experiment and saying whether it is editable. One control returns and reopens the panel on the same experiment's configuration — not the list, not a restarted sequence.
  • Results stay full-screen, opened from configuration as they are today.

Two phases, one PR

Phase Scope
1 The list and the creation entry point. A row's Configure still opens full-screen.
2 Configuration in the panel, including the variant round-trip.

Phase 1 ships usable on its own, and carries the shared plumbing below — phase 2 changes none of it.

The load-bearing work

  • The list store stops owning the URL in panel mode. It hydrates from query params, mirrors every view-state change back with Location.go(…{queryParamsHandling:'merge'}) and re-hydrates on popstate. UVE writes its own params with no merge, replacing the whole query string. Two writers on one address: the panel's state gets silently dropped by UVE's next view change. In the panel the view state lives in memory, seeded from pageId/languageId. This is the largest piece of work here, and it is a store change.
  • One page-scoped request. The list calls getAllUnfiltered() — every experiment on every site — then resolves each distinct pageId through a bulk htmlpageasset lookup and narrows client-side. The panel calls the existing getAll(pageId) and skips the lookup, because UVE already holds pageAsset(). Three requests plus a full-set payload become one. Same swap point as Experiments Portlet — swap the List to the server-side contract #37007 (server-side list contract).
  • The flag read stays fail-closed. Adding it to UVE_FEATURE_FLAGS would invert the default — withFlags maps FEATURE_FLAG_NOT_FOUND to true. The dedicated readExperimentsPortletSwitch stays, and the shell already makes that request for the nav item, so the panel adds none.
  • One implementation of each screen, two presentations. The portlet's list and configuration components are adapted to render as both portlet and sidebar — no parallel copy. LIST_TABLE_STYLE's min-width: 81rem (1296px across seven columns) cannot render at panel width, so the layout is what becomes mode-dependent; the state and the rules are shared. The page column drops in panel mode.
  • Lazy, without hurting fluidity. The experiments code never enters the UVE editor chunk. A UVE load that never opens the panel fires zero experiments requests. The panel responds to the gesture immediately in a loading state; the page never stutters or reloads while it loads.
  • Loading, error, empty and read-only are their own requirements. Because "nothing to show" now moves the editor to creation rather than resting, none of the others may be mistaken for it — a failed load is never an empty page, and a misconfigured analytics app says so.
  • No breadcrumb. Inside UVE the trail belongs to the page.

Must not break

Additional Info

Stacked on issue-37005-experiments-uve-integration (#37256), which is still open — that branch is the base, not main.

The specification is the first commit here (specs/37478-uve-experiments-panel/): 47 functional requirements, 16 success criteria, 7 user stories, 13 resolved decisions. Implementation follows in this same PR.

One correction to the issue. #37478 item 5 states that the list route's DotPushPublishEnvironmentsResolver is read by the row menu, and that a panel mounted outside the router would break Push Publish silently. It is not read — dot-experiments-list.component.ts never touches route data, Push Publish opens through DotPushPublishDialogService with only an assetIdentifier, and dot-add-to-bundle fetches its own bundles. That resolver is a prefetch nothing consumes (contrast dot-locales-list.component.ts:75, which does read it). Both actions therefore work from a panel row with no route plumbing. Recorded as decision D9; the issue has been corrected. The health-gate half of item 5 does hold and is covered.

One decision deliberately left open (O1). The panel's width for phase 2. Configuration is a multi-card form laid out for a full-width column and its Variants card is a table, so it does not fit phase 1's width. Two candidates — a wider panel for that screen only (the Edit Content side panel already varies its own width, 80% to 100%), or a single-column reflow of the cards. It blocks nothing in phase 1.

Related: #37007 (server-side list contract, same swap point), #37008 (migration — this makes it simpler: the experiments routed child and the legacy branch of the nav item both go, and no full-page eject survives). Epic: #36763 (Experiments: A/B Testing v2).

Checklist

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

@claude

claude Bot commented Sep 9, 2026

Copy link
Copy Markdown
Contributor

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


Code Review

Reviewed the phase-1 panel plumbing (store, panel component, EMA shell integration), the constants/i18n, and the tests against origin/main. The store's view-state contract is well-covered by dot-experiments-panel.store.spec.ts, the fail-closed flag read is pinned by a test, and the four message keys used in $titleKey (experiment.container.configuration.title, experiment.container.report.title, experiments.configure.header.new-experiment, plus the new experiments.panel.*) all resolve.

One non-blocking finding:

New Issues

  • 🟡 Medium: core-web/libs/portlets/edit-ema/portlet/src/lib/dot-ema-shell/dot-ema-shell.component.ts:~200 — the dynamic await import('@dotcms/portlets/dot-experiments/portlet') in $experimentsPanelEffect has no failure handling. A chunk-load failure (a real, common event after a deploy while an editor session is still open on the old build) rejects the async callback as an unhandled promise rejection: the panel silently never appears, nothing is logged, and the editor gets no feedback — the science gesture just does nothing. @defer (which this replaces) has a @error block for exactly this; the imperative version dropped it. Suggest wrapping the import() in try/catch and surfacing/logging the failure. Fix this →

Nothing else stood out. The open → close → open and close → clear → resume-after-await races in the same effect are correctly guarded by the re-check of isOpen() and experimentsPanelHost.length === 0 after the await, and store ownership at shell scope (rather than on the panel) is sound for surviving the variant round trip.

Note: the review comments/threads on spec.md (empty-state opening, the page-fixed hint, the badge, and the D15/#37005 flag-lifetime contradiction that erickgonzalez asked to resolve before planning) are spec/product decisions, not code defects — they're outside the scope of this code review and are being tracked in those threads.

· branch issue-37478-uve-experiments-panel

@oidacra
oidacra marked this pull request as ready for review September 9, 2026 21:28
@oidacra
oidacra requested a review from fmontes September 9, 2026 21:28
@oidacra
oidacra force-pushed the issue-37478-uve-experiments-panel branch from db98ee0 to 07837ad Compare September 9, 2026 21:29
Comment thread specs/37478-uve-experiments-panel/spec.md Outdated
Comment thread specs/37478-uve-experiments-panel/spec.md Outdated
Comment thread specs/37478-uve-experiments-panel/spec.md Outdated
Comment thread specs/37478-uve-experiments-panel/spec.md
Comment thread specs/37478-uve-experiments-panel/spec.md

@erickgonzalez erickgonzalez left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just a nitpick:

D15 contradicts an already-approved spec

D15 says so itself: #37005 SC-002 promises an operator switch on this same flag, and FR-047/FR-048 rest on the opposite reframing. Two approved specs cannot both be right, and FR-048 is a hard requirement built on that reading.
Resolve before planning: amend #37005's SC-002 and get it re-approved, or drop FR-048 from this spec.

@oidacra

oidacra commented Sep 10, 2026

Copy link
Copy Markdown
Member Author

@erickgonzalez on D15 and #37005 — you were right that the spec said this, and the spec was wrong to say it. I went back to #37005's text and there is no contradiction to resolve, so neither of the two resolutions is needed. D15 is corrected instead.

#37005 already hands the flag's removal to #37008. Two places in its own text:

Its US3 is also P2 and scoped to "someone who deliberately opts in". So on the flag's lifetime the two specs agree, and D15 was asserting a disagreement that is not in the text.

SC-002 and FR-048 constrain different things. SC-002 measures how fast a flip takes effect ("under one minute, without a deployment or restart") — it says nothing about how long the flag lives or what may be built behind it. FR-048 constrains what may be built on either side. This spec keeps #37005's own uncached, fail-closed reader unchanged (FR-005): readExperimentsPortletSwitch reads the raw key rather than the memoizing getFeatureFlag, precisely so that a flip lands without a restart. SC-002 therefore goes on being satisfied here, unmodified.

FR-048 also conflicts with nothing in #37005 — its FR-014, "the switch MUST NOT change the behavior of any capability other than the ... entry point", pushes the same way.

What changed in the spec: D15's last paragraph no longer claims a contradiction, and instead records this check with the citations above. The note under US3 that said the reframing "needs reconciling with #37005's own spec" is corrected too. FR-047 and FR-048 stand as they were, and #37005 needs no amendment.

On "resolve before planning" — the plan ran after this verification, and nothing in it turned on the outcome: the reader is #37005's, unchanged, and flag-off remains today's behavior exactly. The only planning artifact that moved was the note recording the check.

Base automatically changed from issue-37005-experiments-uve-integration to main September 10, 2026 18:59
…canvas

Spec for #37478. Keeps #37005's premise -- the editor's Experiments gesture
means "the experiments for this page" -- and changes only where they are
shown: beside the page instead of instead of it.

The flow: the panel always opens on the page's list, whose empty state
carries New Experiment; the page is fixed context in creation and
configuration and says why; a variant's Preview or Edit Content leaves for
the page and returns to the same experiment's configuration rather than to
the list; and results render in the panel too, reached from configuration or
from the toolbar's running-experiment badge. Three phases: list,
configuration, results.

Experiments are scoped to a page, not to a language version of one --
DotExperiment carries pageId and no language, and the page-scoped read
narrows on pageId alone. The language #37005 carries from the editor is
return context, not a filter, so creation stores none, a language change
does not re-scope the panel, and the round trip carries it only to land the
editor back on the version they came from.

Grounded against this branch, and two claims are corrected. The list's row
menu does not read push-publish environments off the route, so Push Publish
and Add to Bundle need no route plumbing in a panel (D9). And the editor
already fetches an experiment on every page load to feed an existing
toolbar badge, so a "zero experiments requests" budget was never achievable
-- the budget is that the panel adds none (FR-036). That badge is a second
entry point that deep-links to the legacy route and ejects, so it is brought
into scope (D14), which is what pulls results into the panel (D2).

The flag is a development and QA gate, removed with the legacy screens by
#37008 -- not a long-lived product switch. This contradicts #37005's SC-002,
which promises operators can flip it live; recorded in D15 for resolution in
that spec.

60 FRs, 17 SCs, 7 user stories, 15 resolved decisions, no open
clarifications. Panel width for configuration and results stays an explicit
open decision (O1).
…ifetime

D15 declared a contradiction with #37005 (UVE integration), reading its SC-002
as a promise that FEATURE_FLAG_EXPERIMENTS_PORTLET is a permanent operator
switch. Checked against that spec's text: it says the opposite. Its Scope Note
and its "Relationship to #37008" section both hand the flag's removal to #37008
(migration), together with its shipped default and its wiring, and its US3 is
P2 and scoped to someone who deliberately opts in. The two specs agree on the
flag's lifetime.

SC-002 measures how fast a flip takes effect, not how long the flag lives or
what may be built behind it. FR-005 keeps #37005's own uncached, fail-closed
reader unchanged, so SC-002 goes on holding here. FR-048 conflicts with nothing
in #37005 either — its FR-014, "the switch MUST NOT change the behavior of any
capability other than the entry point", pushes the same way.

D15's last paragraph now records that check with the citations instead of
claiming the conflict, and the note under US3 no longer says the framing needs
reconciling. FR-047 and FR-048 stand as they were; #37005 needs no amendment.

Raised in review on #37485 (this PR).
…y point

Foundation for the Experiments panel (#37478), plus the US3 tests that describe
the entry point it will hang from.

DotExperimentsPanelStore holds what the editor is looking at: whether the panel
is open, which of the four views it shows, and which experiment. It is provided
by the UVE shell rather than by the panel, because the panel component is
mounted and destroyed by the shell's `@if` and the variant round trip needs the
state to survive a period during which the panel is closed. Leaving for a
variant only changes the editor's query params, so the shell is never
re-created; a browser reload does destroy it, which is the case the spec
declines to define.

close() and suspendForVariant() are two named methods rather than one with a
flag. Collapsing them turns the round trip into a reset and the editor loses
their place on every trip.

The store lives in data-access, not beside the screens it serves: edit-ema
reaches the portlet lib only through a dynamic import(), so Nx marks it
lazy-loaded and forbids the static import a shell-level provider needs.
data-access is the non-lazy boundary the two libs already share, and this keeps
the portlet lib fully lazy — which is what the panel's load budget wants.

The three US3 tests asserting the flag-on entry point are intentionally RED
until the nav item stops carrying an href. The flag-off assertions pass now and
must go on passing: they are characterization tests, and they are the regression
net the flag exists to provide.

Also adds the panel's message keys, a UVE editor page object the e2e harness did
not have, and the flag-off e2e suite. Two of its scenarios are recorded as
fixme with reasons: the toolbar badge needs a running experiment, and the
Experiments portlet is opt-in so it sits in no layout on a stock instance.
…canvas

The Experiments entry point stops being a destination (#37478, FR-001, FR-002).
With FEATURE_FLAG_EXPERIMENTS_PORTLET on the navigation item carries no href,
which is what EditEmaNavigationBarComponent already treats as an action, so the
gesture opens a drawer beside the page instead of navigating away from it. The
item also stops being highlighted, since $activeHref only matches items that
have an href — the cost D1 accepts, and it needed no code.

The panel is a p-drawer like the Edit Content side panel, with one deliberate
difference: it is not modal and paints no mask. Edit Content is a full editing
surface and blocking what is behind it is right; this panel must leave the
editor usable, because the editor may navigate to another page while it is open
and the panel re-scopes when they do.

It is created from a dynamic import() the first time it opens and destroyed on
close. @defer would read better but cannot be used: the experiments portlet lib
is reached only through dynamic imports, so Nx marks it lazy-loaded and rejects
any static import of it — and a @defer block still needs the component in
imports:, which is one. import() is what @defer compiles to anyway. The split is
the point: the whole lib stays out of what the editor loads until the panel is
first opened.

Only the list view is wired, and it is still a placeholder. The three US3 tests
that were red now pass.
Expand/collapse with a persisted preference, Escape, click-outside, the
transparent mask and the width transition — the drawer chrome the Edit Content
side panel already has, copied deliberately rather than reinvented.

Each of the four is wrong in a way that is invisible until someone hits it, and
each has already been paid for once:

- Click-outside compares against this drawer's own mask, never the shared
  .p-drawer-mask class, so another drawer's mask cannot close this panel.
- Escape goes through DotKeyboardShortcutService rather than a document
  listener of this component's own. Two document listeners cannot arbitrate —
  preventDefault does not stop the other — so the editor behind would act on
  the same keypress that closed the panel.
- hasOverlayAbove consumes Escape rather than declining it, so the key never
  falls through when something is above.
- The storage reads and writes are best-effort, so a browser with storage
  disabled does not break the panel.

The class docs say all of this in place, name the file it was copied from, and
ask that neither panel be improved alone. The extraction is scoped there too:
the natural moment is phase 2, when this panel gains the unsaved-changes veto
that Edit Content already solved, and DotSidePanelNavController is the piece
that makes it more than a file move.

Where it differs: the mask is transparent because the page behind must stay
visible. The consequence is that the editor is not clickable while the panel is
open — clicking there closes it instead — which is a product call worth seeing
in the running editor before it is settled.
The previous commit used it to withdraw the Escape claim without importing it,
so the app build failed while lint and Jest stayed green — neither typechecks
the way the Angular compiler does.
@oidacra
oidacra force-pushed the issue-37478-uve-experiments-panel branch from 863f9ad to 8718a32 Compare September 10, 2026 21:13
@github-actions github-actions Bot added Area : Frontend PR changes Angular/TypeScript frontend code and removed AI: Safe To Rollback labels Sep 10, 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 : Frontend PR changes Angular/TypeScript frontend code

Projects

Status: No status

Development

Successfully merging this pull request may close these issues.

Experiments Portlet — the page's experiments as a UVE panel, behind FEATURE_FLAG_EXPERIMENTS_PORTLET

3 participants