Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
41 commits
Select commit Hold shift + click to select a range
775f286
refactor(experiments): move legacy UVE screens under old/
oidacra Aug 11, 2026
0d454a2
feat(experiments): add Experiments portlet with site-wide list screen
oidacra Aug 11, 2026
e37a883
feat(experiments): declare the portlet server-side and gate on Analyt…
oidacra Aug 11, 2026
820198e
fix(experiments): provide DotPushPublishEnvironmentsResolver on the l…
oidacra Aug 11, 2026
67466b8
fix(experiments): provide DotExperimentsService on the list component
oidacra Aug 11, 2026
20ef4c5
fix(experiments): hold the list until the Analytics health check answers
oidacra Aug 11, 2026
6d8cef9
test(experiments): add a DI smoke test for the list component
oidacra Aug 11, 2026
0eeba5a
refactor(experiments): align the status filter with the content-drive…
oidacra Aug 11, 2026
5ef7a15
feat(experiments): align the list row with the approved design
oidacra Aug 11, 2026
3c7d6df
refactor(ui): move the chip filter primitives into the shared UI library
oidacra Aug 11, 2026
a1c9c74
refactor(ui): move chip-filter panel styling into the theme preset
oidacra Aug 11, 2026
1b5d70d
refactor(ui): make popover and listbox panel styling global in the theme
oidacra Aug 12, 2026
6db1fbc
fix(experiments): leave only the kebab in the Actions cell
oidacra Aug 12, 2026
54b484a
fix(experiments): match content-drive's list conventions
oidacra Aug 12, 2026
9f95115
fix(experiments): start the status filter empty, with archived opt-in
oidacra Aug 12, 2026
117a449
refactor(experiments): extract list constants and models, drop two fa…
oidacra Aug 12, 2026
b4efb62
refactor(experiments): debounce the search with Angular's debounced()
oidacra Aug 12, 2026
8a84156
feat(experiments): render a load-error state with retry
oidacra Aug 12, 2026
eea8b46
fix(experiments): make the loading skeleton actually render
oidacra Aug 12, 2026
11dab68
refactor(experiments): move archive into the kebab; ComponentStatus a…
oidacra Aug 12, 2026
d6a9e10
refactor(experiments): split page/API events, move helpers and URL sy…
oidacra Aug 12, 2026
37a5086
fix(experiments): make the row kebab blue like content-drive
oidacra Aug 13, 2026
4ab6b03
style(experiments): restore the legacy templates to their pre-move fo…
oidacra Aug 13, 2026
90c322d
fix(experiments): cap the Experiment column and stop the date columns…
oidacra Aug 13, 2026
d3952ee
fix(experiments): tighten the date columns, centre Variants, widen Ex…
oidacra Aug 13, 2026
ce94f49
fix(experiments): size every bounded column to its content, give the …
oidacra Aug 13, 2026
58e8dea
style(experiments): drop the layout comments from the list template
oidacra Aug 13, 2026
f937bf6
fix(experiments): lay the table out fixed so column widths stop follo…
oidacra Aug 13, 2026
d41f754
feat(experiments): add a Goal filter, generalising the chip filter be…
oidacra Aug 13, 2026
bf6160b
feat(experiments): search the description too
oidacra Aug 13, 2026
3ddf8d7
fix(ui): stop DotHighlightPipe injecting unescaped text as live markup
oidacra Aug 13, 2026
867c1da
feat(experiments): add a clear control to the search box
oidacra Aug 13, 2026
f0d0a04
feat(experiments): make Experiment, Page, Goal, Schedule and Status s…
oidacra Aug 13, 2026
00ee2bf
fix(experiments): match content-drive's paginator, and fix a duplicat…
oidacra Aug 13, 2026
3b2c273
fix(experiments): three defects from review, plus doc and test-gap fo…
oidacra Aug 13, 2026
6d9d7be
fix(experiments): separate the two empty states, centre them, soften …
oidacra Aug 13, 2026
b4d1ac6
fix(dotcms-ui): order imports in app.routes.spec
oidacra Aug 13, 2026
1ec87b1
test(core): account for the experiments portlet in the portlet.xml count
oidacra Aug 13, 2026
2b03667
docs(portlets): document what makes a portlet reachable, and what CI …
oidacra Aug 13, 2026
18fe669
fix(experiments): stop a no-op sort from resetting the page on every …
oidacra Aug 13, 2026
f2c07d8
fix(experiments): search the page path the column actually renders
oidacra Aug 13, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 51 additions & 0 deletions core-web/apps/dotcms-ui/src/app/app.routes.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { Route, Routes } from '@angular/router';

import { MenuGuardService } from './api/services/guards/menu-guard.service';
import { appRoutes } from './app.routes';

/**
* Guards the portlet route registration. `MenuGuardService` validates the FIRST url segment
* against `/api/v1/menu`, so a portlet whose path drifts from the menu entry silently stops
* resolving — a failure that is invisible until someone opens the portlet.
*/
describe('appRoutes', () => {
const flatten = (routes: Routes): Route[] =>
routes.flatMap((route) => [route, ...flatten(route.children ?? [])]);

const allRoutes = flatten(appRoutes);

describe('experiments portlet', () => {
const experimentsRoutes = allRoutes.filter((route) => route.path === 'experiments');

it('should be registered exactly once', () => {
expect(experimentsRoutes).toHaveLength(1);
});

it('should use `experiments` as its whole first segment', () => {
// MenuGuardService compares this against the menu entry, so it cannot be
// `experiments/something` nor carry a prefix.
expect(experimentsRoutes[0].path).toBe('experiments');
});

it('should be guarded by MenuGuardService on activation and child activation', () => {
expect(experimentsRoutes[0].canActivate).toContain(MenuGuardService);
expect(experimentsRoutes[0].canActivateChild).toContain(MenuGuardService);
});

it('should not reuse the route', () => {
expect(experimentsRoutes[0].data).toEqual(
expect.objectContaining({ reuseRoute: false })
);
});

it('should lazily load the portlet route array', async () => {
const loadChildren = experimentsRoutes[0].loadChildren;
expect(loadChildren).toBeDefined();

const loaded = await (loadChildren as () => Promise<Routes>)();

expect(Array.isArray(loaded)).toBe(true);
expect(loaded.some((route) => route.path === '')).toBe(true);
});
});
});
10 changes: 10 additions & 0 deletions core-web/apps/dotcms-ui/src/app/app.routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,16 @@ const PORTLETS_ANGULAR: Route[] = [
loadChildren: () =>
import('@dotcms/portlets/dot-es-search/portlet').then((m) => m.dotEsSearchRoutes)
},
{
path: 'experiments',
canActivate: [MenuGuardService],
canActivateChild: [MenuGuardService],
data: { reuseRoute: false },
loadChildren: () =>
import('@dotcms/portlets/dot-experiments/portlet').then(
(m) => m.dotExperimentsPortletRoutes
)
},
{
path: 'tags',
canActivate: [MenuGuardService],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,21 @@ export class DotExperimentsService {
.pipe(map((x) => x?.entity));
}

/**
* Get every experiment, across all pages and sites
*
* Interim contract: the endpoint answers with the full set when no params are sent, so
* paging, sorting and filtering are computed client-side until #36823 lands the
* server-side contract. Keep this method as the single swap point for that change.
* @returns Observable<DotExperiment[]>
* @memberof DotExperimentsService
*/
getAllUnfiltered(): Observable<DotExperiment[]> {
return this.http
.get<DotCMSResponseExperiment<DotExperiment[]>>(API_ENDPOINT)
.pipe(map((x) => x?.entity));
}

/**
* Get an array of experiments of a pageId filter by status
* @param {string} pageId
Expand Down
23 changes: 15 additions & 8 deletions core-web/libs/dotcms-models/src/lib/shared-models.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,21 @@
* |-> IDLE = Finished delete, saving, editing
* ERROR = Error state for the component
**/
export enum ComponentStatus {
INIT = 'INIT',
LOADING = 'LOADING',
LOADED = 'LOADED',
SAVING = 'SAVING',
IDLE = 'IDLE',
ERROR = 'ERROR'
}
export const ComponentStatus = {
INIT: 'INIT',
LOADING: 'LOADING',
LOADED: 'LOADED',
SAVING: 'SAVING',
IDLE: 'IDLE',
ERROR: 'ERROR'
} as const;

/**
* Union of the status values, so a plain `'LOADING'` is assignable where a status is expected —
* an enum member's type would not have been. `as const` over `enum` per TYPESCRIPT_STANDARDS;
* same shape as `HealthStatusTypes`. `ComponentStatus.LOADING` keeps working unchanged.
*/
export type ComponentStatus = (typeof ComponentStatus)[keyof typeof ComponentStatus];

export const enum FeaturedFlags {
LOAD_FRONTEND_EXPERIMENTS = 'FEATURE_FLAG_EXPERIMENTS',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -103,9 +103,13 @@ export class DotCategoryFieldCategoryListComponent {
stateList = ComponentStatus;

$showMainSkeleton = computed(() => {
const isInitialLoadingState = [ComponentStatus.INIT, ComponentStatus.LOADING].includes(
this.$state()
);
// Annotated: with `as const` the literal array would infer as `('INIT' | 'LOADING')[]`,
// which cannot take the wider ComponentStatus that `$state()` returns.
const initialLoadingStates: ComponentStatus[] = [
ComponentStatus.INIT,
ComponentStatus.LOADING
];
const isInitialLoadingState = initialLoadingStates.includes(this.$state());
const categoriesEmpty = this.$categories().length === 0;

return isInitialLoadingState && categoriesEmpty;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,9 @@ export const DEFAULT_MONACO_CONFIG: MonacoEditorConstructionOptions = {
* Represent the able messages to use in the component DotEmptyContainerComponent
*/
export const CATEGORY_FIELD_EMPTY_MESSAGES: Record<
ComponentStatus.ERROR | 'empty' | 'noResults',
// `typeof` because ComponentStatus is an `as const` object, not an enum: its members are
// values, so the member's type is reached through `typeof` rather than directly.
typeof ComponentStatus.ERROR | 'empty' | 'noResults',
PrincipalConfiguration
> = {
empty: {
Expand Down
142 changes: 141 additions & 1 deletion core-web/libs/portlets/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,12 +84,116 @@ export const UVEStore = signalStore(

Each feature slice owns a named prefix in the flat state (e.g. `editor*`, `view*`, `page*`) and exposes only the methods and computeds relevant to its domain. See `libs/portlets/edit-ema/portlet/README.md` for a full example.

## Events-Plugin Pattern (NgRx Signals)

`withState` + store methods stays the default for simple CRUD (`dot-tags`). Reach for the events plugin when:

- Many components dispatch into one store and you do not want to thread method calls through inputs/outputs
- You want an auditable action log (every state change has a named, typed event)
- State transitions and async work should be separated so each can be reasoned about (and tested) on its own

### The pieces

| Piece | Where | Rule |
|-------|-------|------|
| `eventGroup({ source, events: { name: type<Payload>() } })` | `*.events.ts` | One group per source; async flows use `Requested → Succeeded → Failed` triples |
| `withReducer(on(event, ({ payload }, state) => newState))` | store | **Only** place state changes |
| `withEventHandlers` | store | **Only** place for async/HTTP. Handlers **return** their events — `withEventHandlers` dispatches whatever they emit, so no `Dispatcher` here. Use `switchMap` for loads so a re-trigger cancels the in-flight request, and `mergeMap` for per-row actions so acting on one row does not cancel another |
| `injectDispatch(eventGroup)` | component | The store exposes **no methods** for state changes |

### Version note (critical)

The async hook in the installed `@ngrx/signals` (**21.1.1**) is **`withEventHandlers`**. **`withEffects` does not exist** and will not compile — many online examples use that name. Verified exports of `@ngrx/signals/events`:

`event`, `eventGroup`, `on`, `withReducer`, `withEventHandlers`, `injectDispatch`, `Dispatcher`, `Events`, `ReducerEvents`, `mapToScope`, `provideDispatcher`, `toScope`

### Error handling

Same rules as the rest of this guide: the `Failed` handler routes through `DotHttpErrorManagerService.handle(error)` — no custom error UI. A failed LOAD sets `status: ERROR`; a failed CRUD action returns `status` to `LOADED` so the list stays usable. Statuses come from the shared `ComponentStatus` in `@dotcms/dotcms-models` — do not declare a local union.

Split the events by *source*, as the NgRx guide does: what the page asks for, and what the API
answered. The page dispatches only the first group; handlers raise only the second, so the two
halves of an async flow can never be confused. Name page events as commands.

```typescript
// experiments-list-page.events.ts — user intent and lifecycle
export const experimentsListPageEvents = eventGroup({
source: 'Experiments List Page',
events: {
loadExperiments: type<void>()
}
});

// experiments-api.events.ts — what came back
export const experimentsApiEvents = eventGroup({
source: 'Experiments API',
events: {
listSucceeded: type<DotExperiment[]>(),
listFailed: type<unknown>()
}
});

// experiments-list.store.ts
export const ExperimentsListStore = signalStore(
withState(initialState),
withReducer(
// Both groups fold into the one reducer, so reading it tells you who caused each change.
on(experimentsListPageEvents.loadExperiments, (_event, state) => ({
...state,
status: ComponentStatus.LOADING
})),
on(experimentsApiEvents.listSucceeded, ({ payload }, state) => ({
...state,
experiments: payload,
status: ComponentStatus.LOADED
})),
on(experimentsApiEvents.listFailed, (_event, state) => ({
...state,
status: ComponentStatus.ERROR
}))
),
withEventHandlers(() => {
const events = inject(Events);
const service = inject(DotExperimentsService);
const httpErrorManager = inject(DotHttpErrorManagerService);

return {
// Handlers RETURN their events; `withEventHandlers` dispatches whatever they emit, so
// no `Dispatcher` is injected here. `Dispatcher` is still needed in `withHooks`,
// where there is no stream to return into.
loadList$: events.on(experimentsListPageEvents.loadExperiments).pipe(
switchMap(() =>
service.getAll().pipe(
mapResponse({
next: (experiments) => experimentsApiEvents.listSucceeded(experiments),
error: (error: HttpErrorResponse) => {
httpErrorManager.handle(error);

return experimentsApiEvents.listFailed(error);
}
})
)
)
)
};
})
);

// component — dispatches page events only; API events are listened to, never dispatched here
readonly #dispatch = injectDispatch(experimentsListPageEvents);
```

### Reference implementations

- `libs/image-editor/src/lib/store/` — feature-sliced: `image-editor.events.ts` + `features/with-*.feature.ts`
- `libs/portlets/dot-experiments/portlet/src/lib/store/` — single-store list example, with the page/API event split below

## Nx Generator Post-Setup

After running the generator:

```bash
yarn nx generate @nx/angular:library --name=portlet \
pnpm nx generate @nx/angular:library --name=portlet \
--directory=libs/portlets/dot-{feature} \
--tags=type:feature,scope:dotcms-ui,portlet:{feature} \
--prefix=dot --standalone --no-interactive
Expand All @@ -104,6 +208,42 @@ yarn nx generate @nx/angular:library --name=portlet \
5. **tsconfig.spec.json**: keep minimal — only `module`, `target`, `types`
6. **Delete** generated `README.md` and boilerplate component in `src/lib/portlet/`

## Making the portlet reachable

A row in `cms_layouts_portlets` is **not** enough. `MenuHelper.getMenuItems()` resolves every
layout portlet id through `PortletAPI` and silently skips ids it cannot find, so an undeclared
portlet never reaches the menu, `MenuGuardService` rejects the route, and the app redirects to the
first portlet instead. The symptom is a route that "does not exist" with nothing in the console.

1. Declare it in `dotCMS/src/main/webapp/WEB-INF/portlet.xml`.
2. **Bump the count in `SerializationHelperTest.testFromXmlFile`** — it asserts an exact number of
declared portlets, so adding one turns it red (`expected:<N> but was:<N+1>`). Pin the new
portlet by id there too, as the existing entries do; the count alone would still pass if some
other portlet were swapped for yours. This has been part of every portlet migration.
3. The portlet id must equal the **whole first URL segment** of the route: `MenuGuardService`
matches that segment against `/api/v1/menu`.

Declaring is not registering. Without an UpgradeTask or a starter change the portlet stays
invisible to customers until someone adds it to a layout by hand, which is usually what you want
while the screens are still landing.

`portlet.xml` is a webapp resource, so testing this needs a rebuilt image.

## Before you push

CI runs `nx affected -t lint`, `nx affected -t test` and `nx format:check` against `origin/main` —
**every affected project, not just yours**. Linting one project locally is what lets an import-order
error in an app or a sibling lib reach CI. Run what CI runs:

```bash
npx nx affected -t lint --base=origin/main --exclude=tag:skip:lint
npx nx affected -t test --base=origin/main
npx nx format:check --base=origin/main
```

Backend suites run only in a full PR run, so a portlet.xml change can look green in a partial run
and fail later on `SerializationHelperTest`.

## Anti-Patterns

| Do NOT | Do Instead |
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
(removed)="onClearAll()"
data-testid="content-type-filter-chip" />

<p-popover #popover [pt]="popoverPt" (onShow)="$popoverOpen.set(true)" (onHide)="onPanelHide()">
<p-popover #popover (onShow)="$popoverOpen.set(true)" (onHide)="onPanelHide()">
@if ($popoverOpen()) {
<div
class="grid w-160 grid-cols-[repeat(2,1fr)] grid-rows-[min-content] overflow-hidden"
Expand All @@ -24,14 +24,13 @@
optionLabel="label"
optionValue="name"
[scrollHeight]="LISTBOX_SCROLL_HEIGHT"
[pt]="listboxPt"
data-testid="base-type-listbox">
<ng-template let-item pTemplate="item">
@if (item.name === ALL_CONTENT) {
<!--
Absolute-positioned divider extends 1rem past each side to
cover the option's horizontal padding (from
CHIP_FILTER_LISTBOX_PT), without shrinking the content width
the chip-filter panel styles), without shrinking the content width
and truncating the label.
-->
<div class="relative flex w-full min-w-0 items-center gap-2">
Expand Down Expand Up @@ -105,7 +104,6 @@
[scrollHeight]="$rightScrollHeight()"
[lazy]="true"
(onLazyLoad)="onLazyLoad($event)"
[pt]="listboxPt"
optionLabel="name"
dataKey="variable"
data-testid="content-type-listbox">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ import {
DotCMSContentType,
StructureTypeView
} from '@dotcms/dotcms-models';
import { DotChipFilterComponent } from '@dotcms/portlets/content-drive/ui';
import { DotChipFilterComponent } from '@dotcms/ui';
import { MockDotMessageService } from '@dotcms/utils-testing';

import { DotContentDriveContentTypeFilterComponent } from './dot-content-drive-content-type-filter.component';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,12 +32,11 @@ import {
StructureTypeView
} from '@dotcms/dotcms-models';
import {
CHIP_FILTER_LISTBOX_PT,
CHIP_FILTER_POPOVER_PT,
DotChipFilterComponent,
DotFilterListItemComponent
} from '@dotcms/portlets/content-drive/ui';
import { DotMessagePipe } from '@dotcms/ui';
DotFilterListItemComponent,
DotMessagePipe,
LISTBOX_OPTION_HEIGHT
} from '@dotcms/ui';

import {
DEBOUNCE_TIME,
Expand All @@ -50,14 +49,11 @@ const ALL_CONTENT = '__ALL_CONTENT__';
const ITEMS_PER_PAGE = 10;

/**
* Row height (px) used by the right column's virtual scroller.
* Empirically measured against PrimeNG v21 listbox option default styling
* (`--p-listbox-option-padding: 0 1rem` from CHIP_FILTER_LISTBOX_PT, plus the
* `dot-filter-list-item` `py-3` host class). If a future PrimeNG / theme
* upgrade changes the option padding or font, this number needs to be
* re-measured or the scroller will misalign.
* Row height (px) used by the right column's virtual scroller. Taken from the theme, which
* fixes every listbox option to the same height — previously an empirically measured 40.6 that
* silently misaligned the scroller whenever the option padding or font changed.
*/
const LISTBOX_ITEM_HEIGHT = 40.6;
const LISTBOX_ITEM_HEIGHT = LISTBOX_OPTION_HEIGHT;
/** Left listbox viewport height — fits all 9 base-type rows (incl. ALL_CONTENT). */
const LISTBOX_SCROLL_HEIGHT = `${9 * LISTBOX_ITEM_HEIGHT + 14}px`;
/** Approximate column header height (px-4 py-3 with text-xs uppercase). */
Expand Down Expand Up @@ -112,9 +108,6 @@ export class DotContentDriveContentTypeFilterComponent implements OnInit {
* overwrite the current state.
*/
readonly #cancelFetch$ = new Subject<void>();

protected readonly listboxPt = CHIP_FILTER_LISTBOX_PT;
protected readonly popoverPt = CHIP_FILTER_POPOVER_PT;
/**
* PT applied to the base-type checkbox when it's in the indeterminate
* (partial) state — paints the box with the checked-state tokens so the
Expand Down
Loading
Loading