feat(experiments): add Experiments portlet with site-wide list screen - #37034
feat(experiments): add Experiments portlet with site-wide list screen#37034oidacra wants to merge 41 commits into
Conversation
|
Claude finished @oidacra's task in 4m 34s —— View job ReviewI reviewed the store, list component, URL-sync utilities, the Verified sound (not flagged)
New Issues
Nothing blocking. The events-store split, URL round-trip ( `` |
The per-page UVE experiments screens are superseded by the new site-wide Experiments portlet. Move them wholesale under `src/lib/old/` so the new portlet can take the canonical names, and keep them running untouched until they are retired. Pure file moves: no legacy file changed content. `dotExperimentsRoutes` keeps its symbol name and is re-exported from the same barrel, so `edit-ema` and every other consumer are unaffected.
Adds the portlet that makes Experiments reachable outside UVE: a new `/experiments` route rendering a searchable, filterable, sortable and paginated list of every experiment on the current site. Store follows the NgRx Signals events plugin (`eventGroup` + `withReducer` for state, `withEventHandlers` for HTTP with Request/Succeeded/Failed triples). Components dispatch via `injectDispatch`; the store exposes no state-changing methods and owns no UI. Interim by design: `GET /api/v1/experiments` returns the full set, so paging, sorting, filtering and status counts are computed client-side until #36823 lands the server-side contract. The URL param contract is final from day one, so that swap changes only the service and the store's load handler. Site scoping fails closed. `DotExperiment` carries no host, only `pageId`, so one bulk `DotContentSearchService` lookup resolves each page's `url` (the Page column) and `host` (the site filter). An experiment whose page cannot be resolved is excluded rather than shown, so another site's experiment can never leak into the list. No registration UpgradeTask and no feature flag: the portlet is reachable only where a `cms_layouts_portlets` row was inserted manually. No Enterprise licence gate on push-publish or add-to-bundle. Closes #36989
…ics health
Declaring the portlet in portlet.xml is what actually makes /experiments
reachable. MenuHelper resolves every layout portlet id through PortletAPI and
silently skips the ones it cannot find, so a cms_layouts_portlets row alone was
never enough: findPortlet('experiments') returned null, the menu never carried
the entry, MenuGuardService rejected the route and sent the user to the first
portlet instead.
This is a declaration, not a registration. There is still no UpgradeTask and no
starter change, so the portlet stays invisible to customers until someone adds
it to a layout by hand.
Also gates the list on the Analytics health check, matching the behaviour the
legacy UVE routes get from AnalyticsAppGuard. Only OK passes, same as the
guard. The check runs before the first fetch and the list is not requested at
all when it fails, including on a later site switch, so a broken install never
queries experiments behind the misconfiguration screen. URL hydration still
happens first, so a deep link survives once the install is healthy.
Fixes the legacy misconfiguration screen's header, which rendered a skeleton
forever: dot-experiments-ui-header defaults isLoading to true and that template
never opted out.
…ist route `DotPushPublishEnvironmentsResolver` is `@Injectable()` with no `providedIn: 'root'`, so naming it in `resolve` is not enough — the router throws NG0201 "No provider found" the moment the portlet is opened. The legacy UVE route works because `edit-ema` lists it in a route `providers` array; the new route referenced it without providing it. Neither the unit tests nor the AOT build catch this: the resolver is only constructed on route activation. Adds a spec asserting every class resolver the route references is also provided, so the next resolver added here cannot reintroduce it.
Second NG0201 of the same shape: `DotExperimentsService` is `@Injectable()` with no `providedIn` and is not among the app-wide providers in `apps/dotcms-ui/src/app/providers.ts`, so the store could not inject it. The legacy screens provide it in `old/dot-experiments-shell`; the new component now does the same, scoped alongside the store it feeds. `DotAddToBundleComponent` provides its own `AddToBundleService` and `DotCurrentUserService`, and `PushPublishService`, `LoggerService`, `DotHttpErrorManagerService`, `DotMessageService` and `DotMessageDisplayService` are all in the app-wide providers, so this was the only remaining gap.
Entering the portlet rendered the full list chrome (toolbar, search, status chip, table headers) and then replaced it with the "Analytics not Enabled" notice a moment later, a visible layout jump on every entry. The gate treated a null health status as "not misconfigured", so the list won the first paint and lost it once the check resolved. A pending gate is now its own state: neither branch renders until the status is known.
Two NG0201 errors shipped in a row because dotCMS has many `@Injectable()` services with no `providedIn: 'root'`, kept alive by the app-level `providers.ts` that a lazily-loaded standalone portlet does not inherit. Jest missed both because the existing specs mock the store wholesale, and the AOT build missed them because the failure only happens when the injector actually constructs the chain. This spec deliberately does NOT mock the store or `DotExperimentsService`; it builds the component against the real injector and asserts it constructs. Verified it earns its keep: removing `DotExperimentsService` from the component's providers fails all four tests, restoring it makes them pass.
… chip filters The Clear/Done footer came from the issue text, but no `dot-chip-filter` consumer in content-drive has one: language, workflow, field and content-type all apply each toggle immediately and clear through the chip's remove control. An apply button made Experiments the only filter in the admin that behaves differently. Now mirrors `dot-content-drive-language-field`, the closest sibling: a `linkedSignal` bound two-way to the listbox, an `onChange()` that emits, and `onRemoveAll()` for the chip. Counts keep using `dot-filter-list-item`'s `secondary` slot, which that component already does for ISO codes. No search field: language and publishing-queue enable `[filter]` because they list many options, and five statuses do not need one.
Read the design project directly rather than working from the issue summary. The list row was missing two controls it specifies. - New Experiment button in the toolbar. Creation lands with its own screen (#36990+), so it renders disabled with a tooltip rather than being absent. - Primary row action leading the Actions cell: "View Results" where results exist, "Configure" otherwise. Derived from `AllowedActionsByExperimentStatus.results` (RUNNING, ENDED) rather than restating the statuses. Disabled for now: AC10 forbids routing into the legacy UVE screens until Screens 2 and 3 land, so the row matches the design without being able to navigate. - Variants centred and Actions right-aligned, per the design. Column order already matched: the design has no Modified column, and the issue asked for "the design's columns + Modified", which is what ships. Also drops the `dot-experiment-status-tag` wrapper: a component whose only job was to render one `p-tag` earned nothing over inlining the tag and deriving severity, icon and label on the row. Its severity coverage moved to the list spec, so the mapping is still asserted for all five statuses. Reuses `experiments.action.view.results`; adds keys only where the design's copy genuinely differs from an existing string.
`DotChipFilterComponent`, `DotFilterListItemComponent` and the two `CHIP_FILTER_*_PT` constants lived in `@dotcms/portlets/content-drive/ui`, so every portlet that wanted a filter chip imported from another portlet's UI lib. Seven consumers across content-drive, users, publishing-queue and experiments already did exactly that. They now come from `@dotcms/ui`, where shared, domain-agnostic UI belongs. Pure move: no behaviour change. Both components had no content-drive coupling — the only cross-lib import was `DotMessagePipe`, now relative. Only the two `CHIP_FILTER_*` constants moved; `HEADER_COLUMNS`, `ALL_FOLDER` and the rest of content-drive's constants stay put. One test needed adjusting: `libs/ui` runs `@happy-dom/jest-environment` while content-drive-ui runs jsdom, and Spectator's `dispatchKeyboardEvent` builds its event with the legacy `initKeyboardEvent`, which happy-dom does not implement. The two keydown tests now dispatch a real `KeyboardEvent`, which works under both.
…slack to Experiment
Each column whose content is enumerable now carries that bound as its width: the
widest goal label ("Click on Element"), the Variants header (wider than any count
under it), two dates and a separator for Schedule, the "Scheduled" tag, one date
plus the sort icon for Modified, and a single kebab for the actions column.
Experiment is the only column with open-ended content, so it takes `w-full` and
absorbs whatever is left. Its cell is `max-w-0`, which is what lets the existing
truncation bite against the width the table hands it rather than against a fixed
cap — so the name and description get the leftover space instead of the dates
being padded out with it.
Page is open-ended too (paths can be arbitrarily long), so it is bounded and
truncates with a title tooltip rather than competing for that slack.
Goal also needed `whitespace-nowrap` on the body cell: only the header had it, so
labels wrapped onto a second line.
The width, wrap and alignment choices are legible from the classes themselves; narrating them in HTML only adds lines that go stale when a number changes. The three comments left all carry something the markup cannot: which follow-up issue a disabled control is waiting on, and why the error state exists.
…wing the rows The column widths were only hints: with the default `table-layout: auto` the browser sizes each column to the content it can see. Filtering to Draft, whose rows show "Not scheduled" instead of a date range, collapsed the Schedule column and shifted everything after it — the widths changed with the filter. `table-layout: fixed` makes the authored widths authoritative, so they no longer depend on which rows are visible. Same approach content-drive already takes for its list. The gutter problem it documents — fixed layout sharing leftover width across every column — does not arise here because Experiment carries `w-full` and claims the remainder. Covered by a test asserting the layout, verified to fail when the style is removed.
…hind it The Goal chip narrows the list the same way Status does, and the two narrow together: an experiment has to satisfy both, so one with no goal at all drops out as soon as any goal is picked. Selection is URL-backed like every other part of the view state, under a repeatable `goal` param, and unknown values are dropped rather than trusted. Goal counts sit beside the status counts and are computed over the same site-plus-search set, independent of both selections — picking a value must not move the numbers next to the values not yet picked. Rather than copy the status filter, its component is now generic: it takes a title, translated-and-counted options and a selection, and knows nothing about statuses or goals. Adding the next filter is an option list, not another 130 lines. Values are plain strings for that reason and the list component owns the narrower type. The old dot-experiment-status-filter is gone; its spec moves to the generic component and keeps its cases. Two UI decisions worth recording: An empty selection means "no filter", i.e. everything, which the chip now says out loud via an opt-in `emptyLabel` on the shared DotChipFilterComponent — the chips read "Status: All" until something is picked. This is deliberately a placeholder and not an `All` row: a row would be a second source of truth, since checked-All plus a de-selected item is a state that contradicts itself. PrimeNG's select-all is switched off (`showToggleAll`), which is what rendered the unlabeled checkbox above the first option. It also duplicates "All" by another route — ticking every value writes five params to mean what no params already mean — and the chip's remove control is the affordance for getting back. Verified past this portlet since the chip is shared: ui and content-drive pass, dotcms-ui builds. 395 tests.
Search matched the name and the resolved page path but not the description, even though the row renders it directly under the name — so a term the user could read on screen found nothing. Now every field the row shows as text is searched. A missing description is tolerated rather than assumed present. Covered by tests using a term that appears only in the description, verified to fail when the field is dropped from the predicate.
The pipe interpolates the source text straight into an HTML string and hands the result to `bypassSecurityTrustHtml`, which switches Angular's sanitizer off. The text is author-supplied, so anything in it reached the DOM as real markup. Verified before fixing, not assumed: `<img src=x onerror="alert(1)">Alpha` rendered as a live <img> element with its handler intact, through the pipe's own documented `[innerHTML]` usage. Every part is now HTML-escaped before being wrapped, leaving the highlight span as the only markup in the output. The existing 9 tests pass unchanged, since escaping is a no-op for plain text. The one current consumer, dot-language-variable-selector, renders language variable keys through this pipe, so the same input reached it. Four regression tests added, each verified to fail with the escaping removed — including one that asserts against the rendered DOM rather than the string.
Emptying the box meant holding backspace. There is now an X inside the field, shown only while there is something to clear, matching content-drive's search input. Clearing writes the same signal typing does rather than dispatching directly, so it settles through the existing debounce — a direct dispatch would race the pending debounced one and apply the empty term twice. Reachable by keyboard as well as pointer, since it is an icon with no label. Two things the tests had to account for, both verified by probing rather than guessed: NgModel pushes the model back to the input on a microtask, so the DOM value trails the signal by a tick; and the store mock's signals are plain functions, so an applied term has to be typed rather than seeded through `filter`, which a linkedSignal would never recompute from.
…ortable Only Modified could be sorted; every other header was inert. Each column now has a comparator, keyed by a shared `EXPERIMENTS_LIST_SORT_FIELDS` map whose values double as the pSortableColumn field, the `orderby` URL param and the comparator key, so the three cannot drift apart. Choices worth naming: - Text sorts are locale-aware and case-insensitive, so `apple` and `Zebra` order as a reader expects rather than by codepoint, which puts all uppercase first. - Page sorts by the resolved path, not by pageId, which is what the column shows. - Status sorts by lifecycle order — Draft, Scheduled, Running, Ended, Archived — taken from ExperimentsStatusList. Alphabetical would interleave them meaninglessly. - Experiments with no goal, and unscheduled ones, sort to the end while ascending rather than clustering at the top as empty values. - An unrecognised `orderby` leaves the API order untouched instead of throwing, since it is reachable by hand-editing the URL. One existing test asserted that a non-modDate column keeps the API order; that behaviour is what this change removes, so it is now an unrecognised-column test. It had been passing only by coincidence — sorting those two fixtures by name happens to match their modDate order.
…e import The list showed first/last jumps and numbered page links; content-drive shows a 'Page N' report with prev and next only, and the same rows-per-page dropdown. Aligned to that, reusing the existing global 'Page' key rather than adding one. Also fixes a duplicate '@dotcms/dotcms-models' import in the sort comparators, introduced in the previous commit — I read the lint output after the commit had already been chained through, so it went out with the error in it.
…llow-ups
All three criticals verified in the code before changing anything, and each fix
has a test confirmed to fail without it.
1. The page lookup limited to the number of pages. Elasticsearch holds one
document per identifier *and* language, so on a multilingual site the extra
language documents consumed the limit and the response truncated at HTTP 200.
Truncated pages got no entry, and the site filter fails closed — so their
experiments vanished from the list, with a total derived from the same set
that agreed with the short list. Indistinguishable from reality on screen.
The limit now allows headroom for languages, and a shortfall between resolved
and requested ids is reported rather than passing silently. Any language's
document carries the host and url this lookup needs, and duplicates collapse
by identifier, so over-asking costs nothing.
2. Retry after a failed health check skipped the gate. leaves
null, so stayed false and the generic error
branch rendered; its Retry dispatched the list, which has no health guard.
Worse, keys off that null, so the table sat on skeletons forever
even once the list came back. Retry now re-runs the health check, which
resolves the gate either way.
3. A list where no experiment had a resolvable pageId stayed on LOADING.
moves to LOADING for any non-empty payload, but the lookup
returned EMPTY when there were no ids, so nothing settled it. Defensive —
pageId is typed non-optional — but it is one line for a permanent skeleton.
Also from the review: the portlet guide pointed at a path that
no longer exists and taught tapResponse with manual dispatching, which the store
it references does not do; both corrected, and the sample now shows the page/API
event split. Deduped ExperimentPageInfo against the shared DotExperimentPageInfo.
Covered onLazyLoad (offsets, fallbacks, array sort fields, no-field case),
onStatusesChange, and the filter's multi-select toggle, which was untested — the
new tests drive it by clicking options so the model round-trip is real.
4a9a009 to
f7822c7
Compare
…row changes Three things, all visible on an empty list. The message was rendered inside the table, so it sat in a short band directly under the header with the table's bottom border cutting across it, and the rest of the viewport stayed blank. The empty state now replaces the table instead of living in it, so it centres in the space the table would have filled. That also takes the paginator with it, which had been reading "Page 0" against no records. "No experiments yet" was shown whether the site had none or the user's own filters had hidden them all — the screenshot that prompted this had a Scheduled filter applied and still claimed the site was empty. Filtered-empty now says so and offers a way out, which the other case has no use for. Which of the two shows keys off the search term and both chip selections together, so clearing only one of three still reads correctly. Row changes fade in over 140ms rather than swapping in a single frame. Filtering and sorting replace the whole set, and the instant swap read as a jolt. Dropped entirely under `prefers-reduced-motion`, since this is decoration. The DI smoke test now flushes a row and its page lookup instead of an empty list. It was asserting the table renders, which an empty list no longer does — but the weaker reading is that it had been constructing none of the per-row children, and those are exactly what a real injector is meant to be tested against here.
CI lints every affected project; I had been linting only the experiments portlet, so this import-order error in the routes spec never showed up locally. It was the single real failure in the run — every other job reported by `gh pr checks` was cancelled by fail-fast, not failed on its own.
SerializationHelperTest asserts an exact number of declared portlets, so adding experiments to portlet.xml turned it red: expected 56, was 57. Bumped, and the new portlet is now pinned by id the way the others in this test are — the count alone would pass if some unrelated portlet were swapped for it. Verified locally on Java 25 rather than assumed: 9 tests, all passing.
…actually runs Two things this branch learned the hard way, neither written down anywhere. Declaring a portlet in portlet.xml requires bumping the exact count asserted by SerializationHelperTest. That has been part of every portlet migration — users, publishing-queue, velocity-playground, query-tool, es-search — and each one presumably rediscovered it from a red CI run. The section also records why the declaration is needed at all: MenuHelper silently skips portlet ids it cannot resolve, so an undeclared portlet fails as a route that redirects elsewhere with nothing in the console. CI lints and tests every *affected* project. Linting a single project locally, which is the obvious thing to do, is what let an import-order error in an app reach CI from this branch. The exact commands are now in the guide. Also fixes the generator snippet, which still said yarn; the repo is on pnpm.
…lazy load
Paging never advanced past 1, and a /experiments?page=3 deep link snapped back
to page 1 on load.
onLazyLoad dispatched sortChanged whenever the event carried a sortField, and
that reducer resets paging. PrimeNG's createLazyLoadMetadata() puts the current
sortField and sortOrder on every lazy-load event, not just on a sort — confirmed
in the installed source (primeng-table.mjs:2313), with onPageChange emitting
through it at :1549. So clicking page 2 dispatched pageChanged({page:2}) and then
a sort that had not changed, which sent the page straight back to 1. The table's
own first render did the same thing to the page hydrated from the URL.
Now dispatched only when the field or direction actually differs from the store.
The symptom was already visible in this repo: a spec comment noted that rendering
the table fires a lazy load carrying the current sort field. That was written to
work around the noise in an assertion, not read as the bug it was pointing at.
Two tests: a pagination event carrying the unchanged sort dispatches no sort, and
a real direction change still does. The first fails without the guard.
The Page column renders `resolvePagePath`, which falls back to the raw pageId when a page has no url. Search compared against the url alone, so a row showing an id could not be found by typing that id — and the comment directly above the predicate claimed it covered every field the row shows, which was untrue. Both now go through `resolvePagePath`, so the two cannot drift again. Test verified to fail against the previous predicate.
|
Thanks — the critical one was real, and worse than "very likely". Confirmed against the installed PrimeNG rather than inferred: Fixed in Your point about why the tests missed it is the useful part. The spec comment at line 828 was written by me to explain away noise in an assertion, and it was in fact describing the bug. That is the second time on this PR that a comment I wrote to work around a symptom was pointing at a defect. On the two notes: Page path search — valid, fixed in Page-lookup headroom — agreed it is a conscious trade-off, and left as is. The limit cannot be derived without knowing the language count, so the honest options were a generous limit or an extra round trip to count first. The shortfall warning is there to make the wrong guess visible instead of silent. #36823 removes the second request entirely, which is the actual fix. |
There was a problem hiding this comment.
Pull request overview
Adds a new Experiments portlet reachable at /experiments, centered on a site-wide list screen (search/filter/sort/page) and backed by the existing GET /api/v1/experiments endpoint, while also extracting shared chip-filter UI into @dotcms/ui, adjusting global theme styling for popovers/listboxes, and hardening DotHighlightPipe against XSS.
Changes:
- Register the Experiments portlet end-to-end (server
portlet.xml+ menu/routing + i18n + route specs). - Introduce the Experiments list page store/event model and shared list-filter UI components.
- Apply shared UI/theme updates (chip filters, listbox/popover styling) and fix
DotHighlightPipeHTML injection.
Reviewed changes
Copilot reviewed 84 out of 173 changed files in this pull request and generated no comments.
Show a summary per file
| File | Description |
|---|---|
| dotCMS/src/test/java/com/dotmarketing/business/portal/SerializationHelperTest.java | Update portlet.xml portlet count assertion |
| dotCMS/src/main/webapp/WEB-INF/portlet.xml | Declare experiments portlet |
| dotCMS/src/main/webapp/WEB-INF/messages/Language.properties | Add Experiments list i18n keys + portlet title |
| core-web/libs/ui/src/lib/theme/theme.config.ts | Global popover/listbox styling + export listbox row height |
| core-web/libs/ui/src/lib/pipes/dot-highlight/dot-highlight.pipe.ts | Escape untrusted text before bypassSecurityTrustHtml |
| core-web/libs/ui/src/lib/pipes/dot-highlight/dot-highlight.pipe.spec.ts | Add regression tests for escaping + DOM safety |
| core-web/libs/ui/src/lib/components/dot-filter-list-item/dot-filter-list-item.component.ts | Adjust list item layout classes |
| core-web/libs/ui/src/lib/components/dot-filter-list-item/dot-filter-list-item.component.spec.ts | Add basic rendering tests |
| core-web/libs/ui/src/lib/components/dot-chip-filter/dot-chip-filter.component.ts | Add emptyLabel support + local pipe import |
| core-web/libs/ui/src/lib/components/dot-chip-filter/dot-chip-filter.component.spec.ts | Fix keyboard-event dispatch in happy-dom |
| core-web/libs/ui/src/lib/components/dot-chip-filter/dot-chip-filter.component.html | Render empty label when inactive + class ordering |
| core-web/libs/ui/src/index.ts | Export chip-filter + filter-list-item + theme barrel |
| core-web/libs/portlets/edit-ema/ui/src/lib/palette/components/dot-favorite-selector/dot-favorite-selector.component.html | Remove unstyled popover; rely on global theme |
| core-web/libs/portlets/edit-ema/portlet/src/lib/edit-ema-editor/components/dot-uve-toolbar/components/edit-ema-persona-selector/edit-ema-persona-selector.component.html | Remove unstyled popover; rely on global theme |
| core-web/libs/portlets/dot-users/src/lib/dot-users-list/components/dot-users-filter-by/dot-users-filter-by.component.ts | Switch chip-filter imports to @dotcms/ui |
| core-web/libs/portlets/dot-users/src/lib/dot-users-list/components/dot-users-filter-by/dot-users-filter-by.component.html | Drop PT bindings; rely on theme CSS |
| core-web/libs/portlets/dot-publishing-queue/src/lib/components/dot-publishing-queue-status-filter/dot-publishing-queue-status-filter.component.ts | Switch chip-filter imports to @dotcms/ui |
| core-web/libs/portlets/dot-publishing-queue/src/lib/components/dot-publishing-queue-status-filter/dot-publishing-queue-status-filter.component.html | Drop PT bindings; rely on theme CSS |
| core-web/libs/portlets/dot-experiments/portlet/src/test-setup.ts | Shim getComputedStyle/CSS vars for tests |
| core-web/libs/portlets/dot-experiments/portlet/src/lib/store/dot-experiments-list-page.events.ts | Add page-intent event group |
| core-web/libs/portlets/dot-experiments/portlet/src/lib/store/dot-experiments-api.events.ts | Add API-outcome event group |
| core-web/libs/portlets/dot-experiments/portlet/src/lib/shared/models.ts | Add list view/table/filter model types |
| core-web/libs/portlets/dot-experiments/portlet/src/lib/old/shared/ui/dot-experiments-inline-edit-text/dot-experiments-inline-edit-text.component.html | Class ordering/layout tweak |
| core-web/libs/portlets/dot-experiments/portlet/src/lib/old/shared/ui/dot-experiments-header/dot-experiments-ui-header.component.ts | Legacy header component moved under old/ |
| core-web/libs/portlets/dot-experiments/portlet/src/lib/old/shared/ui/dot-experiments-header/dot-experiments-ui-header.component.spec.ts | Add header unit tests |
| core-web/libs/portlets/dot-experiments/portlet/src/lib/old/shared/ui/dot-experiments-header/dot-experiments-ui-header.component.html | Add header template |
| core-web/libs/portlets/dot-experiments/portlet/src/lib/old/shared/ui/dot-experiments-goals-coming-soon/dot-experiments-goals-coming-soon.component.ts | Add “coming soon” goals UI |
| core-web/libs/portlets/dot-experiments/portlet/src/lib/old/shared/ui/dot-experiments-goals-coming-soon/dot-experiments-goals-coming-soon.component.spec.ts | Add goals “coming soon” tests |
| core-web/libs/portlets/dot-experiments/portlet/src/lib/old/shared/ui/dot-experiments-goals-coming-soon/dot-experiments-goals-coming-soon.component.html | Add goals “coming soon” template |
| core-web/libs/portlets/dot-experiments/portlet/src/lib/old/shared/ui/dot-experiments-goal-configuration-url-parameter-component/dot-experiments-goal-configuration-url-parameter-component.component.ts | Legacy goal config component under old/ |
| core-web/libs/portlets/dot-experiments/portlet/src/lib/old/shared/ui/dot-experiments-goal-configuration-url-parameter-component/dot-experiments-goal-configuration-url-parameter-component.component.html | Legacy goal config template under old/ |
| core-web/libs/portlets/dot-experiments/portlet/src/lib/old/shared/ui/dot-experiments-goal-configuration-reach-page/dot-experiments-goal-configuration-reach-page.component.ts | Legacy goal config component under old/ |
| core-web/libs/portlets/dot-experiments/portlet/src/lib/old/shared/ui/dot-experiments-goal-configuration-reach-page/dot-experiments-goal-configuration-reach-page.component.html | Legacy goal config template under old/ |
| core-web/libs/portlets/dot-experiments/portlet/src/lib/old/shared/ui/dot-experiments-details-table/dot-experiments-details-table.component.ts | Legacy details table under old/ |
| core-web/libs/portlets/dot-experiments/portlet/src/lib/old/shared/ui/dot-experiments-details-table/dot-experiments-details-table.component.html | Legacy details table template under old/ |
| core-web/libs/portlets/dot-experiments/portlet/src/lib/old/shared/ui/dot-experiment-options/dot-experiment-options.component.ts | Legacy options control under old/ |
| core-web/libs/portlets/dot-experiments/portlet/src/lib/old/shared/ui/dot-experiment-options/dot-experiment-options.component.spec.ts | Add legacy options control tests |
| core-web/libs/portlets/dot-experiments/portlet/src/lib/old/shared/ui/dot-experiment-options/dot-experiment-options.component.scss | Add CSS expand/collapse transition |
| core-web/libs/portlets/dot-experiments/portlet/src/lib/old/shared/ui/dot-experiment-options/dot-experiment-options.component.html | Add legacy options template |
| core-web/libs/portlets/dot-experiments/portlet/src/lib/old/shared/ui/dot-experiment-options/directives/dot-experiment-options-item.directive.ts | Add options item directive |
| core-web/libs/portlets/dot-experiments/portlet/src/lib/old/shared/ui/dot-experiment-options/directives/dot-experiment-option-content.directive.ts | Add option content directive |
| core-web/libs/portlets/dot-experiments/portlet/src/lib/old/shared/ui/dot-experiment-options/components/dot-experiments-option-content-base-component/dot-experiments-option-content-base.component.ts | Add base component for option content |
| core-web/libs/portlets/dot-experiments/portlet/src/lib/old/shared/guards/dot-experiments-analytic-app.guard.ts | Legacy analytics guard under old/ |
| core-web/libs/portlets/dot-experiments/portlet/src/lib/old/lib.routes.ts | Legacy UVE route tree moved under old/ |
| core-web/libs/portlets/dot-experiments/portlet/src/lib/old/dot-experiments-shell/store/dot-experiments.store.ts | Legacy ComponentStore under old/ |
| core-web/libs/portlets/dot-experiments/portlet/src/lib/old/dot-experiments-shell/dot-experiments-shell.component.ts | Legacy shell component under old/ |
| core-web/libs/portlets/dot-experiments/portlet/src/lib/old/dot-experiments-shell/dot-experiments-shell.component.spec.ts | Add legacy shell tests |
| core-web/libs/portlets/dot-experiments/portlet/src/lib/old/dot-experiments-shell/dot-experiments-shell.component.html | Legacy shell template |
| core-web/libs/portlets/dot-experiments/portlet/src/lib/old/dot-experiments-reports/dot-experiments-reports.component.html | Legacy reports template under old/ |
| core-web/libs/portlets/dot-experiments/portlet/src/lib/old/dot-experiments-reports/components/dot-experiments-reports-skeleton/dot-experiments-reports-skeleton.component.html | Legacy reports skeleton styling tweak |
| core-web/libs/portlets/dot-experiments/portlet/src/lib/old/dot-experiments-reports/components/dot-experiments-reports-chart/dot-experiments-reports-chart.component.spec.ts | Add chart tests under old/ |
| core-web/libs/portlets/dot-experiments/portlet/src/lib/old/dot-experiments-reports/components/dot-experiments-reports-chart/dot-experiments-reports-chart.component.html | Legacy chart template tweak |
| core-web/libs/portlets/dot-experiments/portlet/src/lib/old/dot-experiments-reports/components/dot-experiments-report-daily-details/dot-experiments-report-daily-details.component.ts | Legacy daily details component under old/ |
| core-web/libs/portlets/dot-experiments/portlet/src/lib/old/dot-experiments-reports/components/dot-experiments-report-daily-details/dot-experiments-report-daily-details.component.html | Legacy daily details template tweak |
| core-web/libs/portlets/dot-experiments/portlet/src/lib/old/dot-experiments-reports/components/dot-experiments-experiment-summary/dot-experiments-experiment-summary.component.ts | Legacy summary component under old/ |
| core-web/libs/portlets/dot-experiments/portlet/src/lib/old/dot-experiments-reports/components/dot-experiments-experiment-summary/dot-experiments-experiment-summary.component.html | Legacy summary template under old/ |
| core-web/libs/portlets/dot-experiments/portlet/src/lib/old/dot-experiments-list/dot-experiments-list.component.html | Legacy list template under old/ |
| core-web/libs/portlets/dot-experiments/portlet/src/lib/old/dot-experiments-list/components/dot-experiments-status-filter/dot-experiments-status-filter.component.ts | Legacy status filter under old/ |
| core-web/libs/portlets/dot-experiments/portlet/src/lib/old/dot-experiments-list/components/dot-experiments-status-filter/dot-experiments-status-filter.component.spec.ts | Add legacy status filter tests |
| core-web/libs/portlets/dot-experiments/portlet/src/lib/old/dot-experiments-list/components/dot-experiments-status-filter/dot-experiments-status-filter.component.html | Legacy status filter template |
| core-web/libs/portlets/dot-experiments/portlet/src/lib/old/dot-experiments-list/components/dot-experiments-list-table/dot-experiments-list-table.component.ts | Legacy list table under old/ |
| core-web/libs/portlets/dot-experiments/portlet/src/lib/old/dot-experiments-list/components/dot-experiments-list-table/dot-experiments-list-table.component.html | Legacy list table template tweak |
| core-web/libs/portlets/dot-experiments/portlet/src/lib/old/dot-experiments-list/components/dot-experiments-list-skeleton/dot-experiments-list-skeleton.component.ts | Legacy list skeleton under old/ |
| core-web/libs/portlets/dot-experiments/portlet/src/lib/old/dot-experiments-list/components/dot-experiments-list-skeleton/dot-experiments-list-skeleton.component.html | Legacy skeleton template tweak |
| core-web/libs/portlets/dot-experiments/portlet/src/lib/old/dot-experiments-list/components/dot-experiments-create/dot-experiments-create.component.ts | Legacy create drawer under old/ |
| core-web/libs/portlets/dot-experiments/portlet/src/lib/old/dot-experiments-list/components/dot-experiments-create/dot-experiments-create.component.html | Legacy create drawer template |
| core-web/libs/portlets/dot-experiments/portlet/src/lib/old/dot-experiments-configuration/dot-experiments-configuration.component.html | Legacy configuration template under old/ |
| core-web/libs/portlets/dot-experiments/portlet/src/lib/old/dot-experiments-configuration/components/dot-experiments-configuration-variants-add/dot-experiments-configuration-variants-add.component.ts | Legacy variants add component under old/ |
| core-web/libs/portlets/dot-experiments/portlet/src/lib/old/dot-experiments-configuration/components/dot-experiments-configuration-variants-add/dot-experiments-configuration-variants-add.component.html | Legacy variants add template tweak |
| core-web/libs/portlets/dot-experiments/portlet/src/lib/old/dot-experiments-configuration/components/dot-experiments-configuration-traffic/dot-experiments-configuration-traffic.component.ts | Legacy traffic component under old/ |
| core-web/libs/portlets/dot-experiments/portlet/src/lib/old/dot-experiments-configuration/components/dot-experiments-configuration-traffic/dot-experiments-configuration-traffic.component.html | Legacy traffic template tweak |
| core-web/libs/portlets/dot-experiments/portlet/src/lib/old/dot-experiments-configuration/components/dot-experiments-configuration-traffic-allocation-add/dot-experiments-configuration-traffic-allocation-add.component.html | Legacy traffic allocation template tweak |
| core-web/libs/portlets/dot-experiments/portlet/src/lib/old/dot-experiments-configuration/components/dot-experiments-configuration-targeting/dot-experiments-configuration-targeting.component.ts | Legacy targeting placeholder under old/ |
| core-web/libs/portlets/dot-experiments/portlet/src/lib/old/dot-experiments-configuration/components/dot-experiments-configuration-targeting/dot-experiments-configuration-targeting.component.spec.ts | Add targeting placeholder tests |
| core-web/libs/portlets/dot-experiments/portlet/src/lib/old/dot-experiments-configuration/components/dot-experiments-configuration-targeting/dot-experiments-configuration-targeting.component.html | Legacy targeting placeholder template |
| core-web/libs/portlets/dot-experiments/portlet/src/lib/old/dot-experiments-configuration/components/dot-experiments-configuration-skeleton/dot-experiments-configuration-skeleton.component.ts | Legacy configuration skeleton under old/ |
| core-web/libs/portlets/dot-experiments/portlet/src/lib/old/dot-experiments-configuration/components/dot-experiments-configuration-skeleton/dot-experiments-configuration-skeleton.component.html | Legacy configuration skeleton template |
| core-web/libs/portlets/dot-experiments/portlet/src/lib/old/dot-experiments-configuration/components/dot-experiments-configuration-scheduling/dot-experiments-configuration-scheduling.component.ts | Legacy scheduling component under old/ |
| core-web/libs/portlets/dot-experiments/portlet/src/lib/old/dot-experiments-configuration/components/dot-experiments-configuration-scheduling/dot-experiments-configuration-scheduling.component.html | Legacy scheduling template tweak |
| core-web/libs/portlets/dot-experiments/portlet/src/lib/old/dot-experiments-configuration/components/dot-experiments-configuration-scheduling-add/dot-experiments-configuration-scheduling-add.component.html | Legacy scheduling add template tweak |
| core-web/libs/portlets/dot-experiments/portlet/src/lib/old/dot-experiments-configuration/components/dot-experiments-configuration-items-count/dot-experiments-configuration-items-count.component.ts | Legacy count component under old/ |
| core-web/libs/portlets/dot-experiments/portlet/src/lib/old/dot-experiments-configuration/components/dot-experiments-configuration-items-count/dot-experiments-configuration-items-count.component.spec.ts | Add count component tests |
| core-web/libs/portlets/dot-experiments/portlet/src/lib/old/dot-experiments-configuration/components/dot-experiments-configuration-items-count/dot-experiments-configuration-items-count.component.html | Add count component template |
| core-web/libs/portlets/dot-experiments/portlet/src/lib/old/dot-experiments-configuration/components/dot-experiments-configuration-goal-select/dot-experiments-configuration-goal-select.component.html | Legacy goal select template tweak |
| core-web/libs/portlets/dot-experiments/portlet/src/lib/old/dot-experiments-analytic-app-misconfiguration/dot-experiments-analytic-app-misconfiguration.component.ts | Legacy misconfiguration screen under old/ |
| core-web/libs/portlets/dot-experiments/portlet/src/lib/old/dot-experiments-analytic-app-misconfiguration/dot-experiments-analytic-app-misconfiguration.component.spec.ts | Add misconfiguration screen tests |
| core-web/libs/portlets/dot-experiments/portlet/src/lib/old/dot-experiments-analytic-app-misconfiguration/dot-experiments-analytic-app-misconfiguration.component.html | Ensure header isn’t stuck loading |
| core-web/libs/portlets/dot-experiments/portlet/src/lib/lib.routes.ts | New portlet routes (dotExperimentsPortletRoutes) |
| core-web/libs/portlets/dot-experiments/portlet/src/lib/lib.routes.spec.ts | Spec for portlet route shape + resolver provisioning |
| core-web/libs/portlets/dot-experiments/portlet/src/lib/dot-experiments-list/dot-experiments-list.component.scss | Row transition + reduced-motion handling |
| core-web/libs/portlets/dot-experiments/portlet/src/lib/dot-experiments-analytic-app-misconfiguration/dot-experiments-analytic-app-misconfiguration.component.html | Remove outdated template (moved to old/) |
| core-web/libs/portlets/dot-experiments/portlet/src/lib/components/dot-experiment-list-filter/dot-experiment-list-filter.component.ts | New reusable chip-filter popover component |
| core-web/libs/portlets/dot-experiments/portlet/src/lib/components/dot-experiment-list-filter/dot-experiment-list-filter.component.html | New list-filter template |
| core-web/libs/portlets/dot-experiments/portlet/src/index.ts | Export old + new route arrays |
| core-web/libs/portlets/dot-content-drive/ui/src/lib/shared/constants.ts | Remove chip-filter PT constants (now themed) |
| core-web/libs/portlets/dot-content-drive/ui/src/index.ts | Stop exporting chip-filter UI from content-drive |
| core-web/libs/portlets/dot-content-drive/portlet/src/lib/components/dot-content-drive-toolbar/components/dot-content-drive-workflow-filter/dot-content-drive-workflow-filter.component.ts | Switch chip-filter imports + drop PT |
| core-web/libs/portlets/dot-content-drive/portlet/src/lib/components/dot-content-drive-toolbar/components/dot-content-drive-workflow-filter/dot-content-drive-workflow-filter.component.html | Drop PT bindings; rely on theme |
| core-web/libs/portlets/dot-content-drive/portlet/src/lib/components/dot-content-drive-toolbar/components/dot-content-drive-lazy-multiselect/dot-content-drive-lazy-multiselect.component.ts | Use themed listbox option height constant |
| core-web/libs/portlets/dot-content-drive/portlet/src/lib/components/dot-content-drive-toolbar/components/dot-content-drive-lazy-multiselect/dot-content-drive-lazy-multiselect.component.html | Drop listbox PT binding |
| core-web/libs/portlets/dot-content-drive/portlet/src/lib/components/dot-content-drive-toolbar/components/dot-content-drive-language-field/dot-content-drive-language-field.spec.ts | Switch chip-filter import to @dotcms/ui |
| core-web/libs/portlets/dot-content-drive/portlet/src/lib/components/dot-content-drive-toolbar/components/dot-content-drive-language-field/dot-content-drive-language-field.component.ts | Switch chip-filter imports + drop PT |
| core-web/libs/portlets/dot-content-drive/portlet/src/lib/components/dot-content-drive-toolbar/components/dot-content-drive-language-field/dot-content-drive-language-field.component.html | Drop PT bindings; rely on theme |
| core-web/libs/portlets/dot-content-drive/portlet/src/lib/components/dot-content-drive-toolbar/components/dot-content-drive-field-filter/dot-content-drive-field-filter.component.ts | Switch filter UI imports + drop PT |
| core-web/libs/portlets/dot-content-drive/portlet/src/lib/components/dot-content-drive-toolbar/components/dot-content-drive-field-filter/dot-content-drive-field-filter.component.html | Drop PT bindings; rely on theme |
| core-web/libs/portlets/dot-content-drive/portlet/src/lib/components/dot-content-drive-toolbar/components/dot-content-drive-field-filter-menu/dot-content-drive-field-filter-menu.component.ts | Switch filter UI imports + drop PT |
| core-web/libs/portlets/dot-content-drive/portlet/src/lib/components/dot-content-drive-toolbar/components/dot-content-drive-field-filter-menu/dot-content-drive-field-filter-menu.component.html | Drop PT bindings; rely on theme |
| core-web/libs/portlets/dot-content-drive/portlet/src/lib/components/dot-content-drive-toolbar/components/dot-content-drive-content-type-filter/dot-content-drive-content-type-filter.component.ts | Use themed listbox option height + drop PT |
| core-web/libs/portlets/dot-content-drive/portlet/src/lib/components/dot-content-drive-toolbar/components/dot-content-drive-content-type-filter/dot-content-drive-content-type-filter.component.spec.ts | Switch chip-filter import to @dotcms/ui |
| core-web/libs/portlets/dot-content-drive/portlet/src/lib/components/dot-content-drive-toolbar/components/dot-content-drive-content-type-filter/dot-content-drive-content-type-filter.component.html | Drop PT bindings; rely on theme |
| core-web/libs/edit-content/src/lib/models/dot-edit-content-field.constant.ts | Fix ComponentStatus typing after enum→const |
| core-web/libs/edit-content/src/lib/fields/dot-edit-content-category-field/components/dot-category-field-category-list/dot-category-field-category-list.component.ts | Adjust inferred types after enum→const |
| core-web/libs/dotcms-models/src/lib/shared-models.ts | Convert ComponentStatus enum to as const + type |
| core-web/libs/data-access/src/lib/dot-experiments/dot-experiments.service.ts | Add interim getAllUnfiltered() for portlet |
| core-web/apps/dotcms-ui/src/app/app.routes.ts | Register /experiments portlet route |
| core-web/apps/dotcms-ui/src/app/app.routes.spec.ts | Add spec pinning experiments route contract |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
Summary
Adds the Experiments portlet: a new
/experimentsroute rendering a searchable, filterable, sortable and paginated list of every experiment on the current site. This is the issue that makes the portlet exist, and everything else in #36987 hangs off it.Ships against
GET /api/v1/experimentsas it exists today. No backend dependency, no registration UpgradeTask, no feature flag.CleanShot.2026-08-13.at.13.46.15.mp4
Closes #36989
How to read this
37 commits, rebased on
main. Reviewing them in order is much easier than reading the combined diff — most of the later ones are single-concern fixes found by running the screen.The feature
55723e4old/— 110 files, zero content change662a7624412dffb6e656eThree DI failures, all invisible to Jest and to the AOT build
805d1d8f8caf74DotPushPublishEnvironmentsResolveron the route,DotExperimentsServiceon the component859f51a8f687c2Shared UI (see "Scope beyond the issue")
26aad637cfc299@dotcms/ui8b82736a9cd41b362ae0eDotHighlightPipeinjecting unescaped text as live markupStore shape
5faf36d9264d75util/, move URL write-back into the storec223db5debounced()2cc5718ComponentStatus, converted toas constBehaviour and layout, driven by running it
f79a24bdddc0f8bc35ab4e5ea1932cc571876e8e584e7c8537ffaad0d5aa74a5d0fc66fixed956f9e9e53d0a3ee7af1e920cfee5e6ecb3f7822c73bec27cArchitecture notes
Events store, split by source.
dotExperimentsListPageEventsis what the page asks for;dotExperimentsApiEventsis what the backend answered. The page dispatches only the first, handlers raise only the second, so the two halves of an async flow cannot be confused. Page events read as commands (loadExperiments,archiveExperiment) rather than…Requested. Both fold into onewithReducer, so reading it tells you who caused each transition.Handlers return their events via
mapResponse;withEventHandlersdispatches whatever they emit, so noDispatcheris injected there. Verified against the installed 21.1.1 source: auto-dispatch skips events carryingSOURCE_TYPE, which is stamped only on incoming events as a loop guard.The async hook is
withEventHandlers—withEffectsdoes not exist in@ngrx/signals21.1.1 and will not compile. Written down inlibs/portlets/CLAUDE.mdwith the rest of the pattern.Site scoping fails closed.
DotExperimentcarries no host field, onlypageId. One bulkDotContentSearchServicequery resolves each page'surlfor the Page column andhostfor the site filter. An experiment whose page cannot be resolved is excluded rather than shown, so an unresolvable page can never leak another site's experiment into the list.The store owns the whole URL contract. It parses on entry and on popstate, and writes back through
toQueryParams, the inverse ofparseViewState. Serialization used to live in the component, which split one contract across two files and invited the halves to drift.Client-side list operations are interim by design. The endpoint returns the full set, so paging, sorting, filtering and counts are computed client-side until #36823 lands the server-side contract. The URL param contract is final from day one, so the swap changes only
DotExperimentsService.getAllUnfiltered()and the store's load handler.Analytics health gate. Mirrors what the legacy UVE routes get from
AnalyticsAppGuard: onlyOKpasses. The check runs before the first fetch and the list is never requested when it fails — including on a later site switch. A pending gate is its own render state, otherwise the list paints and is then replaced, which read as a flash on every entry.No Enterprise licence gate. The legacy list gates push-publish and add-to-bundle behind
hasEnterpriseLicense; deliberately not carried over.Why
portlet.xmlchangedThe issue's setup note said a
cms_layouts_portletsrow was enough. It isn't.MenuHelper.getMenuItems()resolves every layout portlet id throughPortletAPIand silently skips what it cannot find. With noexperimentsportlet declared,findPortletreturned null, the menu never carried the entry,MenuGuardServicerejected the route and redirected to the first portlet. Declaring it is what makes/experimentsreachable at all.This is a declaration, not a registration — still no UpgradeTask and no starter change, so the portlet stays invisible to customers until someone adds it to a layout by hand.
Defects found in review, fixed in
f7822c7All three were verified in the code before changing anything, and each fix has a test confirmed to fail without it.
The page lookup truncated the list, silently. It limited to the number of pages, but Elasticsearch holds one document per identifier and language, so a multilingual site returned several documents per page and the response truncated at HTTP 200. Truncated pages resolved to nothing, the site filter fails closed, and their experiments vanished — with a total derived from the same set agreeing with the short list. Indistinguishable from reality on screen. The limit now allows headroom for languages, and a shortfall between resolved and requested ids is reported instead of passing silently.
Retry after a failed health check bypassed the gate.
healthCheckFailedleaveshealthStatusnull, soisMisconfiguredstayed false and the generic error branch rendered; its Retry dispatched the list, which carries no health guard. Worse,$isLoadingkeys off that null, so the table sat on skeletons forever even once the list came back. Retry now re-runs the health check.A list where no experiment had a resolvable
pageIdstayed on LOADING — the lookup returnedEMPTY, so nothing settled the status. Defensive, sincepageIdis typed non-optional, but it is one line standing between a bad payload and a permanent skeleton.Empty states
"Nothing here" and "nothing matched" are different situations and now get different copy. Previously a list emptied by a Status filter still read "No experiments yet", which is wrong and offers nothing to do about it; the filtered case now says so and carries a Clear filters action. Which one shows keys off the search term and both chip selections together, so clearing one of three still reads correctly.
The message also used to render inside the table, so it sat in a short band under the header with the table's bottom border cutting across it while the rest of the viewport stayed blank. It now replaces the table and centres in the space the table would have filled — which also removed a paginator reading "Page 0" against no records.
Row changes fade in over 140ms instead of swapping in a single frame, dropped entirely under
prefers-reduced-motion. Content-drive has no equivalent, so this is new rather than parity.Scope beyond the issue
Each of these was requested during implementation; flagging them so they can be split out if preferred.
old/— this portlet ultimately replaces the per-page UVE screens, so the new code takes the canonical names. Every legacy file moved with zero content change (a87445breverts formatting thatformat:writehad dragged in).dotExperimentsRouteskeeps its symbol name and barrel export, soedit-emais untouched.@dotcms/ui— they lived in@dotcms/portlets/content-drive/ui, so every portlet wanting a filter chip imported from another portlet's UI lib. Seven consumers across four portlets already did.[unstyled]="true"needed adjusting.956f9e9) that no AC asks for. Fully built, tested and URL-synced. Worth adding to Experiments Portlet — Screen 1: portlet base + site-wide List #36989 for traceability, or splitting out.DotHighlightPipeXSS fix (362ae0e) — pre-existing, inlibs/ui, unrelated to this feature. Details below.Security fix
DotHighlightPipeinterpolated author-supplied text into an HTML string and handed the result tobypassSecurityTrustHtml, which switches Angular's sanitizer off. Verified before fixing rather than assumed:<img src=x onerror="alert(1)">Alpharendered a live<img>element with its handler intact, through the pipe's own documented[innerHTML]usage.Every part is now escaped before being wrapped, leaving the highlight span as the only markup. Its one existing consumer,
dot-language-variable-selector, renders language variable keys through it, so the same input reached it. Four regression tests, each verified to fail with the escaping removed, including one asserting against the rendered DOM rather than the string.Acceptance criteria
/experimentsrenders the list for the current sitemodDateDESC by default, design columns plus ModifiedAllowedActionsByExperimentStatusallows, each with confirm and toast, list reloads afterrestorekey or service method until Experiments API gaps for the standalone portlet: archive/restore, PATCH side effects, goal operators, variant cap #36988)nullso a bare/experimentshas no paramsdotExperimentsRoutesunchanged,portlets-edit-ema-portletpasseslibs/portlets/CLAUDE.mddocuments the events-store patterndotExperimentsPortletRoutesexports''only —new,:id/configurationand:id/resultsare omitted rather than stubbed. A placeholder needs a component, and routing to the old UVE screens is forbidden by AC10 and would break anyway (they need the:pageIdshell context). Omitted, an unimplemented deep link falls through to the app catch-all instead of rendering a blank screen.Two deviations from the issue text, both decided during implementation:
dot-chip-filterconsumer in the app has a footer.Setup for QA
The portlet is declared but not registered, so it must be added to a layout by hand. Easiest path, no SQL: Roles & Tools → admin role → add Experiments to a layout (e.g. Marketing). Or:
Then open
/dotAdmin/#/experiments. Requires a rebuilt image, sinceportlet.xmlis a webapp resource.If Analytics is not configured you will get the "Analytics not Enabled" notice instead of the list — that is the health gate working. The repo has a stack for it:
cd docker/docker-compose-examples/experiments ./start-experiments.sh --experiments-onlyGET /api/v1/experiments/healthreturningOKis the single indicator that the list will render.Verification
pnpm nx lint portlets-dot-experiments-portletpnpm nx test portlets-dot-experiments-portletpnpm nx test uipnpm nx test portlets-edit-ema-portletpnpm nx test portlets-content-drivepnpm nx test dotcms-ui --testPathPatterns=app.routespnpm nx build dotcms-uinx affected -t lint --base=origin/main --exclude=tag:skip:lintnx affected -t test --base=origin/mainnx format:check --base=origin/mainAll re-run after rebasing onto
main, not before.The last three are the commands CI itself runs. A single-project lint had been hiding an import-order error in
app.routes.spec.ts, which is what failed the first run on this branch — everything else in that run was cancelled by fail-fast rather than failing on its own.Notes for the reviewer
f7822c7, and the sort comparators, the URL write-back, the fixed table layout and the chip filter toggle, are mutation-checked: the change was reverted locally and the new tests confirmed to fail. Tests that pass without the code they cover are worse than none.apps/dotcms-ui/src/app/app.routes.spec.tsdid not exist, so the issue'sapp.routesverify command was silently finding zero tests. Added one pinning what AC3 needs.--testPathPatternwith--testPathPatterns; the issue's verify command uses the old flag.archivedis defined twice inLanguage.properties(line 345archived=archived, line 5774archived=Archived). Pre-existing and shared with the current UVE filter — left alone.abortcallscancelSchedulein the store. Pre-existing behaviour in the legacy list, replicated for parity, not fixed. The specs assert the dispatched events rather than the HTTP call, so they keep passing when a real abort endpoint arrives.LISTBOX_OPTION_HEIGHT = 40.6is the measured row height and is now a shared constant rather than two hardcoded copies. Review has previously argued for an integer here; keeping the measured value was a deliberate call.