feat(experiments): create/update experiment screen (/experiments/new + /:id/configuration) - #37064
feat(experiments): create/update experiment screen (/experiments/new + /:id/configuration)#37064oidacra wants to merge 47 commits into
Conversation
|
Claude finished @oidacra's task in 3m 12s —— View job Code Review — PR #37064
Reviewed the store, autosave/form utilities, data-access services, routing matcher, and the card components. This is high-quality, heavily-documented code with strong test coverage. Both prior Copilot findings are confirmed fixed. One low-risk edge case worth noting below. New Issues
Resolved
|
a992c18 to
dc6ecad
Compare
🐳 PR Docker test imageLatest build for commit docker pull dotcms/dotcms-test:pr-37064-oidacra-experiments-portlet-screen-2-create-update-exper
docker pull dotcms/dotcms-test:pr-37064-oidacra-experiments-portlet-screen-2-create-update-exper_cf39f75 |
7bc67f0 to
c1da055
Compare
There was a problem hiding this comment.
Pull request overview
This PR introduces the new routed Experiments “Configure” experience so experiment creation/editing happens on dedicated routes (/experiments/new and /experiments/:id/configuration) with autosave-driven POST/PATCH flows, plus supporting UI primitives and data-access to enable page picking, locking awareness, and per-card editing.
Changes:
- Adds Configure routing (single
UrlMatcherroute), screen shell/cards/dialogs, and autosave event/store plumbing for create/update. - Extends shared UI/utilities (radio-card UI, identifier validation utility, browser-selector upload visibility + initial selection).
- Adds/extends data-access and models to support multi-key PATCH and page browsing/lock lookup.
Reviewed changes
Copilot reviewed 70 out of 70 changed files in this pull request and generated 1 comment.
Show a summary per file
| File | Description |
|---|---|
| dotCMS/src/main/webapp/WEB-INF/messages/Language.properties | Adds i18n keys for the new Experiments Configure screen and list “Configure” action. |
| core-web/libs/utils/src/lib/dot-utils.ts | Adds isDotIdentifier UUID-shape guard for safe identifier use in query strings. |
| core-web/libs/utils/src/lib/dot-utils.spec.ts | Adds unit tests for isDotIdentifier. |
| core-web/libs/ui/src/lib/components/dot-radio-card/dot-radio-card.component.ts | New reusable radio-as-card component for signal forms + PrimeNG. |
| core-web/libs/ui/src/lib/components/dot-radio-card/dot-radio-card.component.html | Template for the radio-card component. |
| core-web/libs/ui/src/lib/components/dot-radio-card/dot-radio-card.component.scss | Styling for selected/hover/disabled states (including :has). |
| core-web/libs/ui/src/lib/components/dot-radio-card/dot-radio-card.component.spec.ts | Spectator tests validating radio-card behavior in a real form host. |
| core-web/libs/ui/src/lib/components/dot-browser-selector/dot-browser-selector.component.ts | Adds $canUpload and selection plumbing for the shared browser selector. |
| core-web/libs/ui/src/lib/components/dot-browser-selector/dot-browser-selector.component.html | Wires selected folder id into sidebar and upload visibility into dataview. |
| core-web/libs/ui/src/lib/components/dot-browser-selector/components/dot-sidebar/dot-sidebar.component.ts | Adds selectedId input and updates selected-node derivation. |
| core-web/libs/ui/src/lib/components/dot-browser-selector/components/dot-dataview/dot-dataview.component.ts | Adds showUpload input to allow hiding upload affordances for unuploadable browsers. |
| core-web/libs/ui/src/lib/components/dot-browser-selector/components/dot-dataview/dot-dataview.component.html | Conditionally renders file input + upload button based on showUpload. |
| core-web/libs/ui/src/index.ts | Exports the new dot-radio-card component from the UI library barrel. |
| core-web/libs/portlets/dot-experiments/portlet/src/lib/util/dot-experiments-autosave.util.ts | Adds a single-root-form autosave binding that dispatches diffs to the store. |
| core-web/libs/portlets/dot-experiments/portlet/src/lib/store/dot-experiments-configure-page.events.ts | Defines Configure “page intent” events (enter/load/edit/variants/transitions/prefill). |
| core-web/libs/portlets/dot-experiments/portlet/src/lib/store/dot-experiments-configure-api.events.ts | Defines Configure “API response” events for load/create/save/variants/transitions/prefill/lock. |
| core-web/libs/portlets/dot-experiments/portlet/src/lib/shared/models.ts | Adds Configure screen view state, form model slices, validation rules, and view models. |
| core-web/libs/portlets/dot-experiments/portlet/src/lib/shared/constants.ts | Adds Configure routing constants, autosave timings, copy keys, and picker/dialog constants. |
| core-web/libs/portlets/dot-experiments/portlet/src/lib/lib.routes.ts | Wires Configure route via a single matcher and adds config resolvers/data requirements. |
| core-web/libs/portlets/dot-experiments/portlet/src/lib/lib.routes.spec.ts | Adds matcher tests and resolver/provider/route-reuse assertions. |
| core-web/libs/portlets/dot-experiments/portlet/src/lib/dot-experiments-list/dot-experiments-list.component.ts | Enables New Experiment navigation and adds “Configure” as a leading row action. |
| core-web/libs/portlets/dot-experiments/portlet/src/lib/dot-experiments-list/dot-experiments-list.component.html | Enables New Experiment button, adjusts paginator rows-per-page options rendering. |
| core-web/libs/portlets/dot-experiments/portlet/src/lib/dot-experiments-list/dot-experiments-list.component.scss | Adds screen-enter fade and reduced-motion handling for the list view. |
| core-web/libs/portlets/dot-experiments/portlet/src/lib/dot-experiments-list/dot-experiments-list.component.spec.ts | Updates list specs for Configure action, New Experiment navigation, and pagination UI behavior. |
| core-web/libs/portlets/dot-experiments/portlet/src/lib/dot-experiments-configure/dot-experiments-configure.component.html | Adds the Configure shell layout (header/body/footer, progress bar, locked banner, states). |
| core-web/libs/portlets/dot-experiments/portlet/src/lib/dot-experiments-configure/dot-experiments-configure.component.scss | Adds Configure screen enter animation and progress-bar overlay wrapper. |
| core-web/libs/portlets/dot-experiments/portlet/src/lib/dot-experiments-configure/components/dot-experiments-configure-variants/dot-experiments-variant-name-inplace.component.ts | Adds signal-forms-based inplace variant rename control. |
| core-web/libs/portlets/dot-experiments/portlet/src/lib/dot-experiments-configure/components/dot-experiments-configure-variants/dot-experiments-variant-name-inplace.component.html | Template for inplace variant rename (p-inplace + save/cancel). |
| core-web/libs/portlets/dot-experiments/portlet/src/lib/dot-experiments-configure/components/dot-experiments-configure-variants/dot-experiments-variant-name-inplace.component.spec.ts | Tests rename UX (open/close/trim/disable/max length). |
| core-web/libs/portlets/dot-experiments/portlet/src/lib/dot-experiments-configure/components/dot-experiments-configure-variants/dot-experiments-configure-variants.component.html | Implements Variants card UI (weights, copy URL, split evenly, add/delete, warnings). |
| core-web/libs/portlets/dot-experiments/portlet/src/lib/dot-experiments-configure/components/dot-experiments-configure-variants/dot-experiments-configure-variants.component.scss | Adds animated enter/leave transitions for the weights warning strip. |
| core-web/libs/portlets/dot-experiments/portlet/src/lib/dot-experiments-configure/components/dot-experiments-configure-scheduling/dot-experiments-configure-scheduling.component.ts | Implements Scheduling card logic over a signal-form slice and backend-provided bounds. |
| core-web/libs/portlets/dot-experiments/portlet/src/lib/dot-experiments-configure/components/dot-experiments-configure-scheduling/dot-experiments-configure-scheduling.component.html | Implements Scheduling card UI with date+time pickers, bounds, note, and Clear Schedule. |
| core-web/libs/portlets/dot-experiments/portlet/src/lib/dot-experiments-configure/components/dot-experiments-configure-scheduling/dot-experiments-configure-scheduling.component.spec.ts | Tests scheduling UX and bounds behavior (including time picker rendering). |
| core-web/libs/portlets/dot-experiments/portlet/src/lib/dot-experiments-configure/components/dot-experiments-configure-page/dot-experiments-configure-page.component.ts | Implements Page card (select page dialog, immutability after create, traffic allocation field). |
| core-web/libs/portlets/dot-experiments/portlet/src/lib/dot-experiments-configure/components/dot-experiments-configure-page/dot-experiments-configure-page.component.html | Implements Page card UI with summary, Select button, prefill/required errors, slider+input. |
| core-web/libs/portlets/dot-experiments/portlet/src/lib/dot-experiments-configure/components/dot-experiments-configure-header/dot-experiments-configure-header.component.ts | Implements fixed header with back/title/status/subline and allowed-actions kebab. |
| core-web/libs/portlets/dot-experiments/portlet/src/lib/dot-experiments-configure/components/dot-experiments-configure-header/dot-experiments-configure-header.component.html | Header template including disabled “View Results” placeholder and Stop button. |
| core-web/libs/portlets/dot-experiments/portlet/src/lib/dot-experiments-configure/components/dot-experiments-configure-goal/dot-experiments-configure-goal.component.ts | Implements Goal card using radio cards + conditional panels and start-gated validation display. |
| core-web/libs/portlets/dot-experiments/portlet/src/lib/dot-experiments-configure/components/dot-experiments-configure-goal/dot-experiments-configure-goal.component.html | Goal card template: name input, radio-card type selection, condition panel, errors/hints. |
| core-web/libs/portlets/dot-experiments/portlet/src/lib/dot-experiments-configure/components/dot-experiments-configure-footer/dot-experiments-configure-footer.component.ts | Implements pinned footer hint precedence and Start/Schedule dispatching. |
| core-web/libs/portlets/dot-experiments/portlet/src/lib/dot-experiments-configure/components/dot-experiments-configure-footer/dot-experiments-configure-footer.component.html | Footer template with hint, back button, and Start/Schedule button. |
| core-web/libs/portlets/dot-experiments/portlet/src/lib/dot-experiments-configure/components/dot-experiments-configure-footer/dot-experiments-configure-footer.component.spec.ts | Tests footer hint precedence and start/back behaviors. |
| core-web/libs/portlets/dot-experiments/portlet/src/lib/dot-experiments-configure/components/dot-experiments-configure-details/dot-experiments-configure-details.component.ts | Implements Details card for name/description leaves and start-gated required messaging. |
| core-web/libs/portlets/dot-experiments/portlet/src/lib/dot-experiments-configure/components/dot-experiments-configure-details/dot-experiments-configure-details.component.html | Details card template with name/description inputs and errors/hints. |
| core-web/libs/portlets/dot-experiments/portlet/src/lib/dot-experiments-configure/components/dot-experiments-configure-details/dot-experiments-configure-details.component.spec.ts | Tests details editing, validation reveal behavior, and locked state. |
| core-web/libs/portlets/dot-experiments/portlet/src/lib/dot-experiments-configure/components/dot-experiments-add-variant-dialog/dot-experiments-add-variant-dialog.component.ts | Adds Add Variant dialog using signal forms and deterministic fallback naming. |
| core-web/libs/portlets/dot-experiments/portlet/src/lib/dot-experiments-configure/components/dot-experiments-add-variant-dialog/dot-experiments-add-variant-dialog.component.html | Add Variant dialog template with optional name field and submit/cancel. |
| core-web/libs/portlets/dot-experiments/portlet/src/lib/dot-experiments-configure/components/dot-experiments-add-variant-dialog/dot-experiments-add-variant-dialog.component.spec.ts | Tests typed vs generated names, trimming, validation, and cancel behavior. |
| core-web/libs/dotcms-models/src/lib/dot-experiments.model.ts | Adds DotExperimentPatchBody contract for multi-key PATCH without pageId/targetingConditions. |
| core-web/libs/data-access/src/lib/dot-pages/dot-pages-browser.service.ts | Adds page browsing + lock-state lookup service, with Lucene-injection-safe identifier checks. |
| core-web/libs/data-access/src/lib/dot-pages/dot-pages-browser.models.ts | Adds models for page browsing rows, query params, and lock-info shapes. |
| core-web/libs/data-access/src/lib/dot-experiments/dot-experiments.service.ts | Adds patch() method to support multi-key experiment PATCH in one request. |
| core-web/libs/data-access/src/lib/dot-experiments/dot-experiments.service.spec.ts | Adds tests for multi-key patch, schedule clearing, and payload shape expectations. |
| core-web/libs/data-access/src/index.ts | Re-exports the new DotPagesBrowser service/models from the data-access barrel. |
| core-web/apps/dotcms-ui/src/app/view/components/_common/dot-page-selector/service/dot-page-selector.service.ts | Adds documentation clarifying overlap with the new page browser service. |
| core-web/apps/dotcms-ui/src/app/app.routes.ts | Removes inherited reuseRoute: false from the experiments mount to preserve Configure reuse behavior. |
| core-web/apps/dotcms-ui/src/app/app.routes.spec.ts | Updates routing spec to assert experiments subtree doesn’t force reuseRoute: false. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
f5a6212 to
75ef042
Compare
Every rule now sits in the one `form()` call instead of behind three `apply(path.x, xFormSchema(…))` indirections: reading the shell tells you what the form is, including the goal's length rule, the scheduling date bounds, the per-row weight ranges and the cross-field sum-to-100. Thirty lines, and no file to open to find out what a slice constrains. `WEIGHTS_TOTAL_ERROR_KIND` moves to `shared/constants` along the way: the shell raises it now and the variants card reads it, so a card was the wrong home. The three card specs restate the rules of the slice they mount on, which is the cost of this: they used to import the shell's schema, so their rules were the shell's by construction and now they are a copy. Each declares only what its assertions need, and the shell's own spec is what covers the real wiring. Single-field cards take `[field]`, since naming it after the slice said nothing the call site did not already say: `[trafficAllocationField]` and `[weights]` become `[field]`, joining goal and scheduling. Details keeps `[nameField]` and `[descriptionField]` — it takes two, so there the names carry their weight.
…ing a control The custom control is gone. The card is now a `<label>` around a `p-card` holding a `p-radioButton` bound with `[formField]`, which deletes everything we were maintaining in its place: the `ControlValueAccessor`, the value/option comparison, the click and keydown handlers, `role="radio"` with its `aria-checked` / `aria-disabled` / `tabindex` bookkeeping, and the hand-drawn circle. The earlier NG0201 finding was about a *bare* radio; bound with `[formField]` it works, so none of that was ever necessary. What replaces each piece is the browser's: the whole card is clickable because that is what a label does, the radio carries its own semantics, focus ring and hover, and radios bound to one field share the `name` the interop derives from the field's path — so `dot-radio-group` goes too, and its arrow keys with it. Verified in the browser: a real ArrowRight moves focus and selection between two cards, and the name is `ng.form0.goal.type` on all four. The one loss is Home/End, which native radios do not implement and our group did. The Goal card keeps its cascade through `(picked)`: the radio has written the type by the time it fires, so the handler only adds the proposed name and the fresh condition. Reacting to the pick rather than to the type changing is still what keeps a loaded goal's condition intact — and the debug console.logs that were sitting in that method are gone with it. The card's hover cue is the content hover background, not the form field's hover border: that token is `primary` in this theme, so a hovered card was rendering indistinguishable from the picked one. Both measured at #4e65f1.
The cell drew its own bordered wrapper and asked the input inside to be borderless with `border-none`. That utility lives in Tailwind's `@layer utilities` while PrimeNG's `.p-inputtext` border is unlayered, so PrimeNG won and both borders rendered — the neighbouring `shadow-none!` had the important suffix and did win, which is why only the border showed through. It is now the same `p-inputgroup` + `p-inputgroup-addon` the Page card uses for traffic allocation, so the two traffic inputs on the screen are identical and nothing overrides anything: the group draws no border of its own, the input takes the left half and the addon the right.
The Details card was sniffing the error kind to decide which copy to render —
`errors().some(({ kind }) => kind === 'maxLength')` — which made the card the
place that knows the rule exists and keeps the copy far from the rule it belongs
to. `maxLength` takes a `message`, so the rule now carries it and the card
renders whatever its field reports. Adding a rule to a field surfaces its message
with no card change, and `message` is a function, so the copy resolves lazily
rather than at construction.
This also closes a silent gap: `description` had a length rule and nowhere to
report it, so an overlong description invalidated the form with nothing on
screen. It now has the same slot as the name, and a test each way.
The required error stays where it was: it is not the form's, it is the store's
answer to a Start press, revealed then and cleared by typing.
The remaining three cards stop knowing which rules apply to their slice. Traffic allocation, both scheduling dates and the weights total now attach their copy to the rule in the shell's schema, and each card renders `errors()` — so `$hasTrafficAllocationError`, `$hasStartDateError`, `$hasEndDateError` and the kind-sniffing in `$hasWeightWarning` are all gone, along with the copy that was duplicated away from the rule that raises it. Two of them get better than a straight move: - The end-date bounds message quotes the bounds, and it now reads them from the same place the rules do, so the copy and the constraint can never drift. The shell formats them with the app locale, which is what the card's `date` pipe was doing. - The weights message quotes the total, and the validator already has it, so it is written where it is known instead of being recomputed in the card. Verified in the browser: "Enter a value between 1% and 100%." on an out-of-range allocation and "Variant weights total 90%…" in the amber bar, both clearing when the value is fixed.
One route serves `/experiments/new` and `/experiments/:experimentId/configuration` and the component is reused across them, so both the store and the shell read the route once from the snapshot. A URL arriving while the screen was up therefore changed nothing: landing on `/new` and then opening an experiment left the store in creation mode — one synthetic variant row at 100%, disabled — on a URL that points at a real experiment, and going the other way left the previous experiment's values sitting in a creation form. The store now follows `paramMap`: an id it is not already showing is loaded, no id resets it to a creation form, and the id from its own post-creation swap is ignored, since that experiment is already here with edits possibly pending. Entering another experiment also drops the pending diff — it was typed on the one being left behind. The shell drops `#isCreationScreen` with it, which was the other snapshot read. The form now keys on whichever experiment the store shows, and the case that flag existed for is handled where it belongs: a draft created here is claimed as hydrated when its POST answers, so the answer is never read back over what is still being typed. Verified in the browser both ways: `/new` empties the form to one synthetic row, and returning to the experiment fills it again. Correcting my own report: `replaceUrl` keeps `/new` out of the history (AC3), so Back after creating never hit this. What did was a URL arriving any other way.
`dot-experiments-select-page-dialog` is gone — 1288 lines of component, template, model and spec — and the Page card now opens `DotBrowserSelectorComponent`, the same dialog the file fields and the block editor open, asked for pages only. It answers with the chosen contentlet, and `identifier`, `title` and `url` are all the experiment needed. What made ours look necessary was a rule that does not exist: it greyed out every page already hosting a non-archived experiment. A page can host as many experiments as you like — `ExperimentsAPIImpl.save` puts no page-based restriction on creating one at all. What cannot happen is two *running* at once, which `start` rejects (or, for a scheduled one, an overlapping window). That is enforced where it belongs, at the transition, so blocking the pick was blocking a legal action. Two small changes make the shared browser fit a page picker, both defaulting to today's behaviour for its other two consumers: - The upload button is dropped when the browser lists neither files nor assets. A page cannot be uploaded, so the button was an empty promise. - The folder tree starts on the folder the browser was opened on rather than always on System Host, which is what made it open on an empty System Host listing instead of the current site. `DotPagesBrowserService.getFolderChildren` goes with the dialog that used it; the store keeps `searchPages` for the page prefill and `getPageLockState` for the lock. Eighteen dialog-only i18n keys go too — only the header survives, which the caller still sets.
…ligned Preview / Edit Content and Split Evenly were outlined buttons competing with the weight input for attention, and Split Evenly sat wherever its column started. Both are now text buttons — no border, no fill — pinned to the right edge of their column, so the three rows end on the same line and the only bordered control in the area is the one you type into.
…thing A list that fits in the smallest page size had a working rows-per-page select above it: picking 25 or 50 rendered the same single page either way. PrimeNG only disables that select at zero records, so it stayed live on four experiments. It is now offered only when the total exceeds the smallest option, through `rowsPerPageOptions` — PrimeNG renders it only when that is set, and it already disables the page arrows on a single page, so the whole bar goes inert together and reads "Page 1" with nothing to press. Verified in the browser on a four-experiment list. Dropped rather than greyed out on purpose: keeping it visible and disabled means CSS that a keyboard can walk straight past, since the select is a component and not a native control. This is the framework's own way of saying there is nothing to choose.
The weights only ever travel as a set adding up to 100 — `TrafficProportion` rejects anything else on construction — so leaving the arithmetic to the user meant typing 20 and then working out that the rest is 80, or with three variants, which two numbers add up to the remainder. The row just committed now decides its own share and the rest is spread over the others in the proportion they already had: 20 on a two-row split gives the other 80, and 50/30/20 with 20 committed on the first becomes 20/48/32. The rounding drift lands on the largest adjusted row, so three rows still add up to exactly 100. On commit — blur or Enter — and not on every keystroke, which is what separates this from a UI that fights back: typing 2 on the way to 25 would otherwise send the other rows to 98 and back. Typing alone changes nothing but its own row, and the amber total warning stays for what a commit cannot produce: an experiment arriving from the server at anything but 100. Three cases are deliberately left alone, since there is nothing to spread from: a cleared row, a value the range rules already reject, and a total that adds up already. Rows holding nothing between them share the remainder evenly, having no proportions to keep. Verified in the browser: 20/80 typed to 30 shows 30/80 while typing and settles at 30/70 on blur.
… set Spreading the remainder over every other row undid decisions as they were made: setting 15 on Original and then 50 on a variant moved the 15, which is the one number the user had just been explicit about. The remainder now goes to the rows they have not set. With 15 and then 50 committed, the third row takes the 35 that is left and the 15 stays put. Only when every other row has been set does one have to move, and then it is the one set longest ago — and only as far as the arithmetic demands, widening to the next oldest if that one cannot absorb it alone. Split Evenly forgets every decision, since it overrides every weight: after a split, the rows are all fair game again. Verified in the browser on a three-variant experiment: 27/15/58, commit 15 on Original for 15/17/68 (the two untouched rows share the 85 in proportion), then commit 50 for 15/50/35 — with the 15 untouched.
It appeared and disappeared in a single frame, which is most noticeable exactly when it happens most — while a split is being completed and the total crosses 100. It now enters over 150ms, settling from 2px up so it reads as the strip taking its place, and leaves over 120ms. Same short duration family as the screen's own transition and the content-drive toolbar, through the same `animate.enter` / `animate.leave` mechanism, and dropped entirely under `prefers-reduced-motion`.
… beside it The row already stretches its children and the design asks for exactly that (`align-items: stretch`), but PrimeNG's button keeps its natural height one element in, so it sat 8px short and read as floating above the field. `h-full` on it lets it fill the row. Measured both states: 48px against 48px with a page selected, 41px against 41px while none is, top and bottom flush in each.
`setName(id, name)` was `patch(id, { name })` under another name: same endpoint,
same verb, same mapping. It was added here to complete the legacy family of
single-key setters (`setGoal`, `setDescription`, `setScheduling`, ...) that the
Configure screen exists to replace with one accumulated multi-key call, and
nothing ever called it.
… why it overlaps The folder shapes went with the dialog that used them: `getFolderChildren` was removed when the Page card moved to the shared site browser, but `DotPageBrowserFolder`, `DotPageBrowserFolderChildren` and `DotPageBrowserFolderParams` stayed behind, exported from the barrel and referenced by nothing. Both docblocks still advertised folder navigation as a capability of a service that has two methods, neither of which browses folders. `DotPageSelectorService` already calls the same two endpoints, so the overlap is real and worth explaining rather than leaving for the next reader to find: a library cannot depend on an app, that service answers with the label/value shape an autocomplete needs rather than table rows, and it knows nothing about a page's lock state. Pointers now sit on both sides.
…e old screen does The display/edit swap was hand-rolled here with an `@if` and an `$isEditing` signal, while the old screen builds the same affordance on PrimeNG's `p-inplace` — which the theme already styles (`dotcms-theme/components/_inplace.scss`) and which gives the read state the `role="button"` and keydown handling the `@if` had no equivalent for. What was worth not reusing is the old `dot-experiments-inplace-edit-text` wrapper, which is ReactiveForms-based; the editor stays on signal forms. The docblock claimed that as the reason for hand-rolling the swap too, which it never was. `p-inplace` now owns the swap through `pInplaceDisplay`/`pInplaceContent`, Save and Cancel close it with `deactivate()`, and `onActivate` reseeds the draft from the persisted name so the editor opens the same way whichever affordance opened it. The sixteen existing tests pass untouched.
… fixed Five review findings on the Configure store and its cards. `validationErrors` was a snapshot frozen by the Start press, and only the Details card re-checked its own field against the live value — so seven of the eight rules stayed red after the user fixed them. The state now latches the *reveal* (`validationRevealed`) and the errors derive from the form, which drops the per-card re-checks and makes every rule clear itself. The scroll-to-first-error moves off the error list onto the press that caused it, or it would jump the screen on every keystroke that changed a rule. Autosave could lose an edit outright: `withoutSentKeys` settled a pending key by *name*, and `switchMap` only cancels a flight when the next debounce emits, so an edit made while the response was travelling was dropped with nothing left to resend it. Type, pause, keep typing, stop — and the server kept the older value while the form still showed the newer one. Keys now settle only while their pending value is still the one that went out. `enterExisting` reset the pending diff but not the validation state, so a URL pasted for another experiment carried the previous one's errors over. It now drops everything, as `enterNew` already did. A rejected page lookup was reported as "page not found" and never reached `DotHttpErrorManagerService`: a backend outage told the user the page did not exist and left ops with no signal. It gets its own event, its own copy, and the shared error manager. The variants counter set a static `aria-label` over its own text, so a screen reader heard "Variants used" and never the numbers. Two displaced JSDoc blocks move onto what they describe, and the Details card no longer claims a `required` rule the form never declares.
A PATCH against a local backend answers in a handful of milliseconds, so the indeterminate bar appeared and vanished inside a frame or two: the eye caught that something blue flickered under the header without ever resolving it into "saved", which reads as a glitch rather than as feedback. Once shown the bar now stays for at least `MIN_PROGRESS_BAR_VISIBLE_MS`, timed from when it appeared, so a fast save still gets a legible beat of it and a slow one keeps it up for as long as it takes. A save starting while a hide is pending cancels that hide and leaves the bar where it is, so back-to-back saves read as one continuous bar instead of a blink between them. The store is untouched: `$isSaving` still reports exactly when a request is on the wire, and how long that is worth showing stays with the screen that draws it. Its docblock also picks up a correction — `$isSaving` covers every request the screen makes, not just the autosave.
…tered `validateConfigure` read the goal and the weights off the persisted experiment only, which on `/experiments/new` is still `null`: a goal the user had just filled in went on being counted as missing until picking a page created the draft. Pressing Start on an empty form and working down the list — which is what the reveal is for — left the Goal card contradicting itself. The values were never lost, just unread. `formEdited` merges every key into the pending diff whether or not an experiment exists, and `applyPatchToExperiment` has nothing to apply an edit to before the POST answers, so the goal lives only there. The rules now read the diff first and fall back to the experiment, which is the same shape `draftName` and `selectedPage` already had for the two fields that could be filled in before creation. `minVariants` stays unsatisfiable until the draft exists, and that is not this: adding a variant needs an experiment id, so the card keeps `Add new variant` disabled until the POST answers.
…query
`?pageId=` is whatever the address bar carries, and both page lookups concatenate
it straight into a query string: `+identifier:${pageId}` in the prefill search
and in `getPageLockState`. A value carrying spaces or Lucene operators widens the
query instead of naming an id, so a crafted URL could prefill the Page card with
another contentlet, or answer with another contentlet's lock state. The endpoints
are permission-scoped and read-only, so this is query correctness rather than
privilege escalation — but it is a user-input entry point this screen opened.
`isDotIdentifier` states the shape once, next to the other shared helpers.
Nothing outside a 36-character UUID can name a page, so both call sites answer as
they would for a page that is not there — the prefill reports not-found, the lock
lookup reports unlocked — without spending a request on it.
The fixtures said `page-1`, which is not an identifier any dotCMS install would
produce; a fake that shape is why nothing here looked risky. They are UUIDs now,
which is also what makes the two guard tests meaningful.
Reported by claude[bot] on the PR.
`$canClearSchedule` keyed off the start date, but an end date alone is a schedule
the backend keeps: `toRange` sends `{ startDate: null, endDate }`, which reads as
"start when Start is pressed, stop then". Setting only an end date therefore hid
the control for a schedule that was really there — and its own docblock already
said "before a date is set", not "before a start date is set".
Either date now counts. `clearSchedule` needed nothing: it always dropped both.
Not a dead end before this — each picker carries `showClear`, so the end date
could still be cleared on its own. What was missing was the affordance that drops
the schedule as one choice, in a state where there was a schedule to drop.
Reported by Copilot on the PR.
The footer's Back/Start and the header's View Results/Stop were rendering as pills, which no other labelled p-button in the admin does. Only the icon-only buttons (arrow_back, more_vert) stay rounded, where a circle is the established shape.
The identifier shape was checked against the trimmed value while the query was built from the raw one, so a padded id passed validation and still put whitespace into the Lucene query_string — the very widening the guard above it exists to prevent. Trim once and use that value for both.
v22 makes OnPush the framework default, so setting it says nothing. It was also the only new component in this branch that did.
f38801e to
90bfb31
Compare
…le holds its first column The search box reads "Search" and the first column header reads "Name". The aria-label keeps naming what is being searched through a key of its own, so the shorter placeholder does not cost a screen reader the context. The New button drops its icon, matching the other labelled buttons, and the search box grows a quarter wider. The table gains a min-width. `table-layout: fixed` left Name — the only elastic column — absorbing whatever the fixed ones did not need, so a narrow viewport shrank it towards zero. The floor stops that: below it the table stops shrinking and the scroll container takes over horizontally.
…r icons View Results and Stop Experiment were the only labelled buttons in the header still carrying a leading material symbol. The icon-only controls beside them — back and the kebab — keep theirs, which is the same split the pill already follows.
The Frontend Unit Tests job aborted three runs in a row with a V8 heap OOM partway through `ui:test` — no failing assertion anywhere in the log, just a dead process. The `ui` project peaks near 3.8GB and Node's default ceiling on the runner is ~4.1GB, so it was clearing the bar by under ten percent, and the peak crept up run over run. It reaches that size because the run passes the detectOpenHandles flag, which makes Jest force runInBand: all 106 suites share one process, with coverage on, and the heap only grows. 6144 is deliberate rather than round — nx.json sets "parallel": 3, so three of these can coexist, and a ceiling generous enough to overcommit the runner would trade V8's OOM for the kernel's, which kills the process without leaving a stack behind. Dropping the flag so Jest goes back to workers with separate heaps is the actual fix. It changes how every suite runs, so it belongs on its own.
Summary
Creation becomes a routed screen, not a dialog:
/experiments/newrenders the Configure screen with an empty draft, and/experiments/:experimentId/configurationis the same component in edit mode. Autosave is the creation mechanism — one POST as soon as name + page exist, then areplaceUrlswap to/:id/configuration; every later change is a debounced PATCH per field group. Functional parity with the legacy UVE configuration screen, with the approved design and 100% Angular Signal Forms (no ReactiveForms in new code).Closes #37003
CleanShot.2026-08-18.at.16.21.05.mp4
What's included
dot-experiments-configure.store.ts): Signal Store Events pattern (same shape as the list store), debouncedswitchMapautosave per field group, one-shot creation guard,?pageId=/?url=prefill, page-lock awareness, the 8 start-gated validation rules, transitions (start/schedule/stop/cancelSchedule/abort) and non-DRAFT locking (SCHEDULED included, matching the legacy screen).p-datepicker [showTime]+ min/max duration bounds fromDotExperimentsConfigResolver).Variant {n}fallback).UrlMatcherroute config serves both paths so the post-creationreplaceUrlswap reuses the component instance (no autosave loss); theexperimentsmount dropsreuseRoute: false(mirrors the/contentprecedent).DotPagesBrowserService(page search, folder tree, page-lock lookup) +DotExperimentsService.setName.Acceptance criteria
All 10 issue ACs verified by an AC-coverage pass (48 refined criteria, all covered) — routing/creation, debounced autosave with
targetingConditionsomitted from every payload, prefill with inline error, the five cards with ported behaviors, Select A Page grey-out, reveal-on-Start validation with scroll + footer count, transitions with toasts, kebab perAllowedActionsByExperimentStatus(no enterprise gates), old UVE screen untouched, Jest specs per card + autosave store with fake timers.Test plan
nx lintclean on the three touched projects;nx build dotcms-ui(production AOT) passes.nx affectedalso pulls inblock-editor/dotcms-block-editor, which fail lint/tests on the baseline without any change from this branch (zero files touched there; additive-only data-access exports) — pre-existing failures, not introduced here.Notes and follow-ups
PATCH /api/v1/experiments/{id}does not acceptpageId— the page is set at creation only; the UI disables Select afterwards with a tooltip (legacy parity: the page was never changeable).ExperimentForm.BuilderresetstargetingConditionsto[]on every PATCH regardless of the client payload — belongs to the Experiments API gaps for the standalone portlet: archive/restore, PATCH side effects, goal operators, variant cap #36988 scope.targetingConditionsin payloads) is enforced by spec.