diff --git a/core-web/apps/dotcms-ui/src/app/app.routes.spec.ts b/core-web/apps/dotcms-ui/src/app/app.routes.spec.ts new file mode 100644 index 000000000000..8a67807e76c7 --- /dev/null +++ b/core-web/apps/dotcms-ui/src/app/app.routes.spec.ts @@ -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)(); + + expect(Array.isArray(loaded)).toBe(true); + expect(loaded.some((route) => route.path === '')).toBe(true); + }); + }); +}); diff --git a/core-web/apps/dotcms-ui/src/app/app.routes.ts b/core-web/apps/dotcms-ui/src/app/app.routes.ts index bd1ca719595b..3a9bbf571073 100644 --- a/core-web/apps/dotcms-ui/src/app/app.routes.ts +++ b/core-web/apps/dotcms-ui/src/app/app.routes.ts @@ -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], diff --git a/core-web/libs/data-access/src/lib/dot-experiments/dot-experiments.service.ts b/core-web/libs/data-access/src/lib/dot-experiments/dot-experiments.service.ts index e8e2815691dc..d13fc81558a4 100644 --- a/core-web/libs/data-access/src/lib/dot-experiments/dot-experiments.service.ts +++ b/core-web/libs/data-access/src/lib/dot-experiments/dot-experiments.service.ts @@ -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 + * @memberof DotExperimentsService + */ + getAllUnfiltered(): Observable { + return this.http + .get>(API_ENDPOINT) + .pipe(map((x) => x?.entity)); + } + /** * Get an array of experiments of a pageId filter by status * @param {string} pageId diff --git a/core-web/libs/dotcms-models/src/lib/shared-models.ts b/core-web/libs/dotcms-models/src/lib/shared-models.ts index 8cf0ae7bd604..a7f2ddb3e22b 100644 --- a/core-web/libs/dotcms-models/src/lib/shared-models.ts +++ b/core-web/libs/dotcms-models/src/lib/shared-models.ts @@ -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', diff --git a/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 b/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 index 078f103e3a0c..b06f5236a753 100644 --- a/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 +++ b/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 @@ -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; diff --git a/core-web/libs/edit-content/src/lib/models/dot-edit-content-field.constant.ts b/core-web/libs/edit-content/src/lib/models/dot-edit-content-field.constant.ts index 32b8ad2ee179..4f254d08801c 100644 --- a/core-web/libs/edit-content/src/lib/models/dot-edit-content-field.constant.ts +++ b/core-web/libs/edit-content/src/lib/models/dot-edit-content-field.constant.ts @@ -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: { diff --git a/core-web/libs/portlets/CLAUDE.md b/core-web/libs/portlets/CLAUDE.md index 42e16f1e490f..5820e37bdf56 100644 --- a/core-web/libs/portlets/CLAUDE.md +++ b/core-web/libs/portlets/CLAUDE.md @@ -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() } })` | `*.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() + } +}); + +// experiments-api.events.ts — what came back +export const experimentsApiEvents = eventGroup({ + source: 'Experiments API', + events: { + listSucceeded: type(), + listFailed: type() + } +}); + +// 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 @@ -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: but was:`). 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 | diff --git a/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 b/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 index f2a18ab6440c..194de718c8da 100644 --- a/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 +++ b/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 @@ -5,7 +5,7 @@ (removed)="onClearAll()" data-testid="content-type-filter-chip" /> - + @if ($popoverOpen()) {
@if (item.name === ALL_CONTENT) {
@@ -105,7 +104,6 @@ [scrollHeight]="$rightScrollHeight()" [lazy]="true" (onLazyLoad)="onLazyLoad($event)" - [pt]="listboxPt" optionLabel="name" dataKey="variable" data-testid="content-type-listbox"> diff --git a/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 b/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 index 8f57c0058531..469efed7a232 100644 --- a/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 +++ b/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 @@ -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'; diff --git a/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 b/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 index 1f1a8111ab05..146e70b34ca9 100644 --- a/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 +++ b/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 @@ -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, @@ -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). */ @@ -112,9 +108,6 @@ export class DotContentDriveContentTypeFilterComponent implements OnInit { * overwrite the current state. */ readonly #cancelFetch$ = new Subject(); - - 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 diff --git a/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 b/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 index 1d71a3259b58..a0a744809023 100644 --- a/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 +++ b/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 @@ -14,7 +14,7 @@ data-testid="field-filter-more-button" /> - +
{{ 'content-drive.field-filter.more.header' | dm }} @@ -38,7 +38,6 @@ [options]="$availableFields()" optionLabel="name" [scrollHeight]="'18rem'" - [pt]="listboxPt" data-testid="field-filter-menu-listbox">
- + @if ($popoverOpen()) { @switch ($control()) { @case ('text') { @@ -57,7 +53,6 @@ [filter]="true" [filterPlaceHolder]="'search' | dm" [scrollHeight]="LISTBOX_SCROLL_HEIGHT" - [pt]="listboxPt" optionLabel="label" optionValue="value" data-testid="field-filter-select"> @@ -79,7 +74,6 @@ [filter]="true" [filterPlaceHolder]="'search' | dm" [scrollHeight]="LISTBOX_SCROLL_HEIGHT" - [pt]="listboxPt" optionLabel="label" optionValue="value" (onChange)="onMultiChange()" @@ -109,7 +103,6 @@ [filter]="true" [filterPlaceHolder]="'search' | dm" [scrollHeight]="LISTBOX_SCROLL_HEIGHT" - [pt]="listboxPt" optionLabel="label" optionValue="value" (onChange)="onMultiChange()" @@ -129,7 +122,6 @@ [ngModelOptions]="{ standalone: true }" [options]="$binaryOptions()" [scrollHeight]="LISTBOX_SCROLL_HEIGHT" - [pt]="listboxPt" optionLabel="label" optionValue="value" data-testid="field-filter-binary"> @@ -156,7 +148,6 @@ [filter]="true" [filterPlaceHolder]="'search' | dm" [scrollHeight]="LISTBOX_SCROLL_HEIGHT" - [pt]="listboxPt" optionLabel="label" optionValue="value" data-testid="field-filter-radio"> diff --git a/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 b/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 index 3d37c1846350..882c80c45e51 100644 --- a/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 +++ b/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 @@ -36,13 +36,7 @@ import { getContentTypeIdFromRelationship, getSingleSelectableFieldOptions } from '@dotcms/edit-content'; -import { - CHIP_FILTER_LISTBOX_PT, - CHIP_FILTER_POPOVER_PT, - DotChipFilterComponent, - DotFilterListItemComponent -} from '@dotcms/portlets/content-drive/ui'; -import { DotMessagePipe } from '@dotcms/ui'; +import { DotChipFilterComponent, DotFilterListItemComponent, DotMessagePipe } from '@dotcms/ui'; import { DotContentDriveRelationshipFooterComponent } from './dot-content-drive-relationship-footer/dot-content-drive-relationship-footer.component'; @@ -132,9 +126,6 @@ export class DotContentDriveFieldFilterComponent { readonly #contentletService = inject(DotContentletService); readonly #dialogService = inject(DialogService); readonly #destroyRef = inject(DestroyRef); - - protected readonly listboxPt = CHIP_FILTER_LISTBOX_PT; - protected readonly popoverPt = CHIP_FILTER_POPOVER_PT; protected readonly LISTBOX_SCROLL_HEIGHT = PANEL_SCROLL_HEIGHT; /** * Flattens the inline date picker's own panel chrome (border/shadow/rounding) so it blends into diff --git a/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 b/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 index da309f061a96..377858a7fac2 100644 --- a/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 +++ b/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 @@ -5,7 +5,7 @@ (removed)="onRemoveAll()" data-testid="language-chip" /> - + diff --git a/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 b/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 index 1fa30ffad124..19b2826c309a 100644 --- a/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 +++ b/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 @@ -15,13 +15,7 @@ import { PopoverModule } from 'primeng/popover'; import { DotLanguagesService } from '@dotcms/data-access'; import { DotLanguage } 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'; +import { DotChipFilterComponent, DotFilterListItemComponent, DotMessagePipe } from '@dotcms/ui'; import { PANEL_SCROLL_HEIGHT } from '../../../../shared/constants'; import { DotContentDriveStore } from '../../../../store/dot-content-drive.store'; @@ -58,8 +52,6 @@ export class DotContentDriveLanguageFieldComponent implements OnInit { }); protected readonly LISTBOX_SCROLL_HEIGHT = PANEL_SCROLL_HEIGHT; - protected readonly popoverPt = CHIP_FILTER_POPOVER_PT; - protected readonly listboxPt = CHIP_FILTER_LISTBOX_PT; protected readonly $selectedLanguageNames = computed(() => { const ids = this.$selectedLanguages() ?? []; diff --git a/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 b/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 index 627e49f917bb..68ff4e355134 100644 --- a/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 +++ b/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 @@ -14,7 +14,7 @@ import { Popover } from 'primeng/popover'; import { DotLanguagesService, DotMessageService } from '@dotcms/data-access'; import { DotLanguage } from '@dotcms/dotcms-models'; -import { DotChipFilterComponent } from '@dotcms/portlets/content-drive/ui'; +import { DotChipFilterComponent } from '@dotcms/ui'; import { createFakeLanguage, MockDotMessageService } from '@dotcms/utils-testing'; import { DotContentDriveLanguageFieldComponent } from './dot-content-drive-language-field.component'; diff --git a/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 b/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 index 8d9515d278d8..8f43dc7fd2bc 100644 --- a/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 +++ b/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 @@ -25,7 +25,6 @@ [lazy]="true" (onLazyLoad)="onLazyLoad($event)" [scrollHeight]="SCROLL_HEIGHT" - [pt]="listboxPt" optionLabel="label" optionValue="value" dataKey="value" diff --git a/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 b/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 index fae52932ed35..6cf1ca3769c6 100644 --- a/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 +++ b/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 @@ -22,11 +22,7 @@ import { ScrollerLazyLoadEvent } from 'primeng/scroller'; import { catchError, debounceTime, take, takeUntil } from 'rxjs/operators'; -import { - CHIP_FILTER_LISTBOX_PT, - DotFilterListItemComponent -} from '@dotcms/portlets/content-drive/ui'; -import { DotMessagePipe } from '@dotcms/ui'; +import { DotFilterListItemComponent, DotMessagePipe, LISTBOX_OPTION_HEIGHT } from '@dotcms/ui'; import { DEBOUNCE_TIME, PANEL_SCROLL_HEIGHT } from '../../../../shared/constants'; @@ -49,11 +45,10 @@ export type DotLazyMultiselectLoader = (params: { }) => Observable; /** - * Row height (px) for the virtual scroller — matches the content-type filter's listbox, measured - * against PrimeNG v21 option styling (`--p-listbox-option-padding: 0 1rem` from - * CHIP_FILTER_LISTBOX_PT plus the `dot-filter-list-item` `py-3` host class). + * Row height (px) for the virtual scroller. Taken from the theme, which fixes every listbox + * option to the same height, so this can never drift from what is actually rendered. */ -const ITEM_HEIGHT = 40.6; +const ITEM_HEIGHT = LISTBOX_OPTION_HEIGHT; /** Page size requested from the loader. */ const PER_PAGE = 20; @@ -99,8 +94,6 @@ export class DotContentDriveLazyMultiselectComponent implements OnInit { readonly $selectedValues = input([], { alias: 'selectedValues' }); /** Emits the selected options (value + label) whenever the selection changes. */ readonly selectionChange = output(); - - protected readonly listboxPt = CHIP_FILTER_LISTBOX_PT; protected readonly SCROLL_HEIGHT = PANEL_SCROLL_HEIGHT; protected readonly ITEM_HEIGHT = ITEM_HEIGHT; diff --git a/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 b/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 index 98c4be893672..91e34b8dd1a3 100644 --- a/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 +++ b/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 @@ -5,7 +5,7 @@ (removed)="onClearAll()" data-testid="workflow-filter-chip" /> - +
@@ -99,7 +98,6 @@ optionLabel="name" optionValue="id" [scrollHeight]="LISTBOX_SCROLL_HEIGHT" - [pt]="listboxPt" data-testid="workflow-step-listbox">
diff --git a/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 b/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 index 81a5cd6e7796..bf94916fd090 100644 --- a/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 +++ b/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 @@ -24,13 +24,7 @@ import { catchError, take } from 'rxjs/operators'; import { DotHttpErrorManagerService, DotWorkflowService } from '@dotcms/data-access'; import { DotCMSWorkflow, WorkflowStep } 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'; +import { DotChipFilterComponent, DotFilterListItemComponent, DotMessagePipe } from '@dotcms/ui'; import { PANEL_SCROLL_HEIGHT } from '../../../../shared/constants'; import { DotContentDriveStore } from '../../../../store/dot-content-drive.store'; @@ -98,9 +92,6 @@ export class DotContentDriveWorkflowFilterComponent { readonly #destroyRef = inject(DestroyRef); readonly #workflowService = inject(DotWorkflowService); readonly #httpErrorManager = inject(DotHttpErrorManagerService); - - protected readonly listboxPt = CHIP_FILTER_LISTBOX_PT; - protected readonly popoverPt = CHIP_FILTER_POPOVER_PT; protected readonly LISTBOX_SCROLL_HEIGHT = PANEL_SCROLL_HEIGHT; readonly $state = signalState({ diff --git a/core-web/libs/portlets/dot-content-drive/ui/src/index.ts b/core-web/libs/portlets/dot-content-drive/ui/src/index.ts index 02c40257bb2a..66aa63a10276 100644 --- a/core-web/libs/portlets/dot-content-drive/ui/src/index.ts +++ b/core-web/libs/portlets/dot-content-drive/ui/src/index.ts @@ -1,6 +1,4 @@ export * from './lib/dot-folder-list-view/dot-folder-list-view.component'; export * from './lib/dot-tree-folder/dot-tree-folder.component'; -export * from './lib/dot-chip-filter/dot-chip-filter.component'; -export * from './lib/dot-filter-list-item/dot-filter-list-item.component'; export * from './lib/shared/models'; export * from './lib/shared/constants'; diff --git a/core-web/libs/portlets/dot-content-drive/ui/src/lib/shared/constants.ts b/core-web/libs/portlets/dot-content-drive/ui/src/lib/shared/constants.ts index cb4e5eb661e1..df0ad2be35f7 100644 --- a/core-web/libs/portlets/dot-content-drive/ui/src/lib/shared/constants.ts +++ b/core-web/libs/portlets/dot-content-drive/ui/src/lib/shared/constants.ts @@ -60,31 +60,3 @@ export const ALL_FOLDER: DotFolderTreeNodeItem = { leaf: false, expanded: true }; - -/** - * Pass-through styling for the popover that hosts a chip-filter listbox. - * Removes default content padding and rounds the corners. - */ -export const CHIP_FILTER_POPOVER_PT = { - root: { class: '!rounded-lg overflow-hidden' }, - content: { class: '!p-0' } -}; - -/** - * Pass-through styling for the listbox rendered inside a chip-filter popover. - * Strips the listbox's own chrome, applies palette colors for selection/hover, - * and sizes option padding + checkbox to the content-drive design spec. - */ -export const CHIP_FILTER_LISTBOX_PT = { - root: { - class: [ - '!border-0 !rounded-none !shadow-none', - '[--p-listbox-option-padding:0_1rem]', - '[--p-listbox-option-focus-background:var(--p-slate-50)]', - '[--p-listbox-option-selected-color:var(--p-primary-700)]', - '[--p-listbox-option-selected-focus-color:var(--p-primary-700)]', - '[--p-listbox-option-selected-focus-background:var(--p-listbox-option-selected-background)]', - '[--p-checkbox-width:16px] [--p-checkbox-height:16px]' - ].join(' ') - } -}; diff --git a/core-web/libs/portlets/dot-experiments/portlet/src/index.ts b/core-web/libs/portlets/dot-experiments/portlet/src/index.ts index 44c9365302f3..743e0a6c2f62 100644 --- a/core-web/libs/portlets/dot-experiments/portlet/src/index.ts +++ b/core-web/libs/portlets/dot-experiments/portlet/src/index.ts @@ -1 +1,2 @@ +export * from './lib/old/lib.routes'; export * from './lib/lib.routes'; diff --git a/core-web/libs/portlets/dot-experiments/portlet/src/lib/components/dot-experiment-list-filter/dot-experiment-list-filter.component.html b/core-web/libs/portlets/dot-experiments/portlet/src/lib/components/dot-experiment-list-filter/dot-experiment-list-filter.component.html new file mode 100644 index 000000000000..ec81304688bf --- /dev/null +++ b/core-web/libs/portlets/dot-experiments/portlet/src/lib/components/dot-experiment-list-filter/dot-experiment-list-filter.component.html @@ -0,0 +1,29 @@ + + + + + + + + + diff --git a/core-web/libs/portlets/dot-experiments/portlet/src/lib/components/dot-experiment-list-filter/dot-experiment-list-filter.component.spec.ts b/core-web/libs/portlets/dot-experiments/portlet/src/lib/components/dot-experiment-list-filter/dot-experiment-list-filter.component.spec.ts new file mode 100644 index 000000000000..3ec31f1a3986 --- /dev/null +++ b/core-web/libs/portlets/dot-experiments/portlet/src/lib/components/dot-experiment-list-filter/dot-experiment-list-filter.component.spec.ts @@ -0,0 +1,150 @@ +import { byTestId, createComponentFactory, Spectator } from '@openng/spectator/jest'; + +import { DotMessageService } from '@dotcms/data-access'; +import { MockDotMessageService } from '@dotcms/utils-testing'; + +import { DotExperimentListFilterComponent } from './dot-experiment-list-filter.component'; + +import { ExperimentFilterOption } from '../../shared/models'; + +const OPTIONS: ExperimentFilterOption[] = [ + { value: 'DRAFT', label: 'Draft', count: '3', testId: 'option-draft' }, + { value: 'RUNNING', label: 'Running', count: '2', testId: 'option-running' }, + { value: 'ENDED', label: 'Ended', count: '0', testId: 'option-ended' } +]; + +describe('DotExperimentListFilterComponent', () => { + let spectator: Spectator; + + const createComponent = createComponentFactory({ + component: DotExperimentListFilterComponent, + providers: [ + { + provide: DotMessageService, + useValue: new MockDotMessageService({ + 'dot.common.remove': 'Remove', + 'content-drive.chip-filter.overflow-label': '{0} +{1}' + }) + } + ], + detectChanges: false + }); + + /** The listbox lives inside the popover, which only renders once the chip opens it. */ + const openPopover = (): void => { + spectator.click(spectator.query(byTestId('experiment-list-filter-chip')) as HTMLElement); + spectator.detectChanges(); + }; + + /** Toggles an option the way a user does, so the model round-trip is exercised. */ + const clickOption = (testId: string): void => { + openPopover(); + spectator.click(spectator.query(byTestId(testId)) as HTMLElement); + spectator.detectChanges(); + }; + + const captureSelectionChange = (): jest.Mock => { + const selectionChange = jest.fn(); + spectator.output('selectionChange').subscribe(selectionChange); + + return selectionChange; + }; + + const setUp = (selected: string[] = []) => { + spectator = createComponent({ + props: { + title: 'Status', + emptyLabel: 'All', + options: OPTIONS, + selected + } as unknown as Partial + }); + spectator.detectChanges(); + }; + + describe('chip', () => { + it('should read as the placeholder while nothing is selected', () => { + setUp(); + + // Empty means "no filter", i.e. everything — said on the chip rather than offered as + // an `All` row, which would contradict the individual checkboxes. + expect(spectator.query(byTestId('chip-title'))?.textContent?.trim()).toBe('Status'); + expect(spectator.query(byTestId('chip-empty-label'))?.textContent).toContain('All'); + expect(spectator.query(byTestId('chip-values'))).toBeNull(); + }); + + it('should list the selected labels once something is picked', () => { + setUp(['DRAFT', 'RUNNING']); + + expect(spectator.query(byTestId('chip-values'))?.textContent).toContain( + 'Draft, Running' + ); + expect(spectator.query(byTestId('chip-empty-label'))).toBeNull(); + }); + + it('should ignore a selected value it has no option for', () => { + setUp(['DRAFT', 'NOT_AN_OPTION']); + + expect(spectator.query(byTestId('chip-values'))?.textContent).toContain('Draft'); + expect(spectator.query(byTestId('chip-values'))?.textContent).not.toContain( + 'NOT_AN_OPTION' + ); + }); + }); + + describe('selection', () => { + it('should emit the whole selection when an option is toggled on', () => { + setUp(['DRAFT']); + const selectionChange = captureSelectionChange(); + + clickOption('option-ended'); + + // Emits its own state, not just the option that moved. + expect(selectionChange).toHaveBeenCalledWith(['DRAFT', 'ENDED']); + }); + + it('should emit the remainder when an option is toggled off', () => { + setUp(['DRAFT', 'ENDED']); + const selectionChange = captureSelectionChange(); + + clickOption('option-ended'); + + expect(selectionChange).toHaveBeenCalledWith(['DRAFT']); + }); + + it('should emit an empty array rather than null when the last option is unticked', () => { + setUp(['DRAFT']); + const selectionChange = captureSelectionChange(); + + clickOption('option-draft'); + + // PrimeNG can hand back null once nothing is selected; the store expects an array. + expect(selectionChange).toHaveBeenCalledWith([]); + }); + + it('should emit the emptied selection when the chip is cleared', () => { + setUp(['DRAFT']); + + const selectionChange = jest.fn(); + spectator.output('selectionChange').subscribe(selectionChange); + + spectator.click(spectator.query(byTestId('chip-remove')) as HTMLElement); + + expect(selectionChange).toHaveBeenCalledWith([]); + }); + + it('should reflect the applied selection back into the chip after the parent changes it', () => { + setUp(['DRAFT']); + + expect(spectator.query(byTestId('chip-values'))?.textContent).toContain('Draft'); + + // Stands in for URL hydration / back-forward: the parent owns the applied selection, + // so a change underneath has to re-seed what the chip and listbox show. + spectator.setInput('selected', ['ENDED']); + spectator.detectChanges(); + + expect(spectator.query(byTestId('chip-values'))?.textContent).toContain('Ended'); + expect(spectator.query(byTestId('chip-values'))?.textContent).not.toContain('Draft'); + }); + }); +}); diff --git a/core-web/libs/portlets/dot-experiments/portlet/src/lib/components/dot-experiment-list-filter/dot-experiment-list-filter.component.ts b/core-web/libs/portlets/dot-experiments/portlet/src/lib/components/dot-experiment-list-filter/dot-experiment-list-filter.component.ts new file mode 100644 index 000000000000..a03317be05e4 --- /dev/null +++ b/core-web/libs/portlets/dot-experiments/portlet/src/lib/components/dot-experiment-list-filter/dot-experiment-list-filter.component.ts @@ -0,0 +1,85 @@ +import { Component, computed, input, linkedSignal, output } from '@angular/core'; +import { FormsModule } from '@angular/forms'; + +import { ButtonModule } from 'primeng/button'; +import { ListboxModule } from 'primeng/listbox'; +import { PopoverModule } from 'primeng/popover'; + +import { DotChipFilterComponent, DotFilterListItemComponent } from '@dotcms/ui'; + +import { LISTBOX_SCROLL_HEIGHT } from '../../shared/constants'; +import { ExperimentFilterOption } from '../../shared/models'; + +/** + * Multi-select chip filter for the experiments list, used once per filterable column. + * + * Renders a chip that opens a popover with a checkbox listbox. Each toggle applies immediately + * and the chip's remove control clears the selection, matching every `dot-chip-filter` consumer + * in content-drive — none of them batch behind an apply button, so this one does not either. + * + * Deliberately knows nothing about statuses or goals: options arrive already translated and + * counted, so adding a filter is a matter of supplying a new option list rather than copying + * this component. Values are plain strings for that reason; the caller owns the narrower type + * and casts on the way out. + */ +@Component({ + selector: 'dot-experiment-list-filter', + imports: [ + FormsModule, + ButtonModule, + ListboxModule, + PopoverModule, + DotChipFilterComponent, + DotFilterListItemComponent + ], + templateUrl: './dot-experiment-list-filter.component.html' +}) +export class DotExperimentListFilterComponent { + /** Chip label, already translated. */ + readonly $title = input.required({ alias: 'title' }); + + /** Every value the filter offers, already translated and counted. */ + readonly $options = input.required({ alias: 'options' }); + + /** Currently applied values, owned by the parent's store (URL-backed). */ + readonly $selected = input.required({ alias: 'selected' }); + + /** + * What the chip reads while nothing is selected — `All`, since an empty selection applies no + * filter. Kept as a placeholder rather than an entry in the list: see `emptyLabel` on + * `DotChipFilterComponent`. + */ + readonly $emptyLabel = input.required({ alias: 'emptyLabel' }); + + /** Emits on every toggle and on clear. */ + readonly selectionChange = output(); + + protected readonly LISTBOX_SCROLL_HEIGHT = LISTBOX_SCROLL_HEIGHT; + + /** + * Labels the chip renders, and what makes it read as active. The filter starts empty, so the + * chip is neutral until the user picks something — no special case needed. + */ + protected readonly $selectedLabels = computed(() => { + const selected = new Set(this.$selected()); + + return this.$options() + .filter(({ value }) => selected.has(value)) + .map(({ label }) => label); + }); + + /** + * Bound two-way to the listbox. Re-seeds from the applied selection whenever the parent + * changes it (URL hydration, back/forward), while staying writable by the listbox. + */ + protected readonly $selectedValues = linkedSignal(() => [...this.$selected()]); + + protected onChange(): void { + this.selectionChange.emit(this.$selectedValues() ?? []); + } + + protected onRemoveAll(): void { + this.$selectedValues.set([]); + this.selectionChange.emit([]); + } +} diff --git a/core-web/libs/portlets/dot-experiments/portlet/src/lib/dot-experiments-analytic-app-misconfiguration/dot-experiments-analytic-app-misconfiguration.component.html b/core-web/libs/portlets/dot-experiments/portlet/src/lib/dot-experiments-analytic-app-misconfiguration/dot-experiments-analytic-app-misconfiguration.component.html deleted file mode 100644 index 21dceff6c6bb..000000000000 --- a/core-web/libs/portlets/dot-experiments/portlet/src/lib/dot-experiments-analytic-app-misconfiguration/dot-experiments-analytic-app-misconfiguration.component.html +++ /dev/null @@ -1,4 +0,0 @@ - -
- -
diff --git a/core-web/libs/portlets/dot-experiments/portlet/src/lib/dot-experiments-list/dot-experiments-list.component.html b/core-web/libs/portlets/dot-experiments/portlet/src/lib/dot-experiments-list/dot-experiments-list.component.html index 9a4fea8dfc21..c33ac45a5a61 100644 --- a/core-web/libs/portlets/dot-experiments/portlet/src/lib/dot-experiments-list/dot-experiments-list.component.html +++ b/core-web/libs/portlets/dot-experiments/portlet/src/lib/dot-experiments-list/dot-experiments-list.component.html @@ -1,45 +1,252 @@ -@if (vm$ | async; as vm) { - -
- @if (vm.experiments.length) { -
- - +@if (store.isMisconfigured()) { +
+ +
+} @else if ($hasError()) { + +
+ +
+} @else { + +
+ + + + + + @if ($searchTerm()) { + + } + + + + + + + + +
+
+ +
+ @if ($isEmpty()) { +
+
- } @else { - + + + + + {{ 'experiments.list.header.experiment' | dm }} + + + + {{ 'experiments.list.header.page' | dm }} + + + + {{ 'experiments.list.header.goal' | dm }} + + + + {{ 'experiments.list.header.variants' | dm }} + + + {{ 'experiments.list.header.schedule' | dm }} + + + + {{ 'experiments.list.header.status' | dm }} + + + + {{ 'experiments.list.header.modified' | dm }} + + + + + + + + @if ($isLoading()) { + + @for (column of SKELETON_COLUMNS; track column) { + + + + + + } + + } @else { + + +
+ + {{ row.experiment.name }} + + @if (row.experiment.description) { + + {{ row.experiment.description }} + + } +
+ + + {{ row.pagePath }} + + + {{ + row.goalLabelKey ? (row.goalLabelKey | dm) : NO_GOAL_PLACEHOLDER + }} + + + {{ row.variants }} + + + {{ row.schedule }} + + + + + + {{ row.experiment.modDate | date: 'MMM d, y' }} + + + +
+ + + +
+ + + } +
+
}
- @if (vm.addToBundleContentId) { + + + + @if ($addToBundleAssetId(); as assetIdentifier) { + [assetIdentifier]="assetIdentifier" + (cancel)="$addToBundleAssetId.set(null)" /> } -} - - - - - - - - - + +} diff --git a/core-web/libs/portlets/dot-experiments/portlet/src/lib/dot-experiments-list/dot-experiments-list.component.scss b/core-web/libs/portlets/dot-experiments/portlet/src/lib/dot-experiments-list/dot-experiments-list.component.scss new file mode 100644 index 000000000000..bd94fe1daa13 --- /dev/null +++ b/core-web/libs/portlets/dot-experiments/portlet/src/lib/dot-experiments-list/dot-experiments-list.component.scss @@ -0,0 +1,27 @@ +/** + * Filtering, sorting and paging all swap the rows underneath the header. Without this the set + * changes in a single frame, which reads as a jolt; a short fade makes it register as a change + * rather than a flicker. Deliberately brief — this should be felt more than seen. + * + * Applied to the rows rather than the table so the header, toolbar and paginator stay put. + */ +:host ::ng-deep tbody > tr { + animation: dot-experiments-row-enter 140ms ease-out; +} + +@keyframes dot-experiments-row-enter { + from { + opacity: 0; + } + + to { + opacity: 1; + } +} + +// Motion is decoration here, so it is the first thing to drop for anyone who asked for less. +@media (prefers-reduced-motion: reduce) { + :host ::ng-deep tbody > tr { + animation: none; + } +} diff --git a/core-web/libs/portlets/dot-experiments/portlet/src/lib/dot-experiments-list/dot-experiments-list.component.spec.ts b/core-web/libs/portlets/dot-experiments/portlet/src/lib/dot-experiments-list/dot-experiments-list.component.spec.ts index 17a823851a9d..b9f639ab7dae 100644 --- a/core-web/libs/portlets/dot-experiments/portlet/src/lib/dot-experiments-list/dot-experiments-list.component.spec.ts +++ b/core-web/libs/portlets/dot-experiments/portlet/src/lib/dot-experiments-list/dot-experiments-list.component.spec.ts @@ -1,430 +1,961 @@ -import { createComponentFactory, mockProvider, Spectator } from '@openng/spectator/jest'; -import { MockComponent } from 'ng-mocks'; -import { BehaviorSubject, of } from 'rxjs'; +import { Dispatcher, EventCreator } from '@ngrx/signals/events'; +import { byTestId, createComponentFactory, mockProvider, Spectator } from '@openng/spectator/jest'; -import { provideHttpClient } from '@angular/common/http'; -import { provideHttpClientTesting } from '@angular/common/http/testing'; -import { fakeAsync, tick } from '@angular/core/testing'; -import { ActivatedRoute, Router } from '@angular/router'; +import { provideLocationMocks } from '@angular/common/testing'; +import { provideRouter } from '@angular/router'; -import { ConfirmationService, MessageService } from 'primeng/api'; +import { ConfirmationService, Confirmation, MenuItem } from 'primeng/api'; -import { - DotExperimentsService, - DotHttpErrorManagerService, - DotMessageService, - DotFormatDateService -} from '@dotcms/data-access'; -import { - DotPushPublishDialogService, - LoginService, - DotcmsConfigService, - LoggerService -} from '@dotcms/dotcms-js'; +import { DotMessageDisplayService, DotMessageService } from '@dotcms/data-access'; +import { DotPushPublishDialogService } from '@dotcms/dotcms-js'; import { ComponentStatus, + DotExperiment, DotExperimentStatus, - DotExperimentsWithActions, - SidebarStatus + DotMessageSeverity, + GOAL_TYPES, + HealthStatusTypes } from '@dotcms/dotcms-models'; -import { DotAddToBundleComponent } from '@dotcms/ui'; -import { getExperimentMock } from '@dotcms/utils-testing'; +import { getExperimentMock, MockDotMessageService } from '@dotcms/utils-testing'; import { DotExperimentsListComponent } from './dot-experiments-list.component'; -import { DotExperimentsListStore, VmListExperiments } from './store/dot-experiments-list-store'; -import { DotExperimentsStore } from '../dot-experiments-shell/store/dot-experiments.store'; +import { + DEFAULT_EXPERIMENTS_LIST_DIRECTION, + DEFAULT_EXPERIMENTS_LIST_ORDER_BY, + DEFAULT_EXPERIMENTS_LIST_GOALS, + DEFAULT_EXPERIMENTS_LIST_PAGE, + DEFAULT_EXPERIMENTS_LIST_PER_PAGE, + DEFAULT_EXPERIMENTS_LIST_STATUSES, + SEARCH_DEBOUNCE_MS +} from '../shared/constants'; +import { dotExperimentsApiEvents } from '../store/dot-experiments-api.events'; +import { dotExperimentsListPageEvents } from '../store/dot-experiments-list-page.events'; +import { DotExperimentsListStore } from '../store/dot-experiments-list.store'; + +const PAGE_ID = 'page-1'; + +const PAGE_INFO = { [PAGE_ID]: { url: '/blog/index', host: 'host-1' } }; + +const EMPTY_GOAL_COUNTS: Record = { + [GOAL_TYPES.REACH_PAGE]: 0, + [GOAL_TYPES.BOUNCE_RATE]: 0, + [GOAL_TYPES.CLICK_ON_ELEMENT]: 0, + [GOAL_TYPES.URL_PARAMETER]: 0, + [GOAL_TYPES.EXIT_RATE]: 0 +}; + +const EMPTY_STATUS_COUNTS: Record = { + [DotExperimentStatus.DRAFT]: 0, + [DotExperimentStatus.SCHEDULED]: 0, + [DotExperimentStatus.RUNNING]: 0, + [DotExperimentStatus.ENDED]: 0, + [DotExperimentStatus.ARCHIVED]: 0 +}; + +const experimentWith = (status: DotExperimentStatus): DotExperiment => ({ + ...getExperimentMock(0), + id: `experiment-${status.toLowerCase()}`, + name: `Experiment ${status}`, + pageId: PAGE_ID, + status +}); -const EXPERIMENT_MOCK_DRAFT = getExperimentMock(0); -const EXPERIMENT_MOCK_RUNNING = { - ...getExperimentMock(1), - status: DotExperimentStatus.RUNNING +/** Kebab item ids, as declared by the component. */ +const MENU_ITEM = { + archive: 'experiments-archive', + restore: 'experiments-restore', + cancelSchedule: 'experiments-cancel-schedule', + end: 'experiments-end', + abort: 'experiments-abort', + delete: 'experiments-delete', + pushPublish: 'experiments-push-publish', + addToBundle: 'experiments-add-to-bundle' +} as const; + +const ARCHIVE_LABEL = 'Archive'; +const RESTORE_LABEL = 'Restore'; +const ACTIONS_MENU_LABEL = 'Actions'; + +const NOT_CONFIGURED_COPY = { + title: 'Analytics not configured', + subtitle: 'Configure the Analytics app to start experimenting' }; -const EXPERIMENT_MOCK_ENDED = { - ...getExperimentMock(2), - status: DotExperimentStatus.ENDED + +const MISCONFIGURATION_COPY = { + title: 'Analytics misconfigured', + subtitle: 'Review the Analytics app configuration' }; -const EXPERIMENT_MOCK_SCHEDULED = { - ...getExperimentMock(3), - status: DotExperimentStatus.SCHEDULED + +const ERROR_COPY = { + title: 'Could not load experiments', + subtitle: 'Failed to retrieve experiments data' }; +const messageServiceMock = new MockDotMessageService({ + 'experiments.analytics-app-no-configured.title': NOT_CONFIGURED_COPY.title, + 'experiments.analytics-app-no-configured.subtitle': NOT_CONFIGURED_COPY.subtitle, + 'experiments.analytics-app-misconfiguration.title': MISCONFIGURATION_COPY.title, + 'experiments.analytics-app-misconfiguration.subtitle': MISCONFIGURATION_COPY.subtitle, + 'experiments.action.archive': ARCHIVE_LABEL, + 'experiments.action.restore': RESTORE_LABEL, + 'experiments.list.actions.menu': ACTIONS_MENU_LABEL, + 'experiments.action.archive.confirm-message': 'Experiment {0} archived', + 'experiments.action.delete.confirm-message': 'Experiment {0} deleted', + 'experiments.action.stop.confirm-message': 'Experiment {0} ended', + 'experiments.notification.abort': 'Experiment {0} aborted', + 'experiments.notification.cancel.schedule': 'Experiment {0} unscheduled', + 'experiments.list.error.title': ERROR_COPY.title, + 'experiments.error.fetching.data': ERROR_COPY.subtitle, + 'experiments.list.error.retry': 'Retry' +}); + +/** + * The store is provided by the component itself, so it is replaced through + * `componentProviders`. Signals are plain `jest.fn()` return values: the component only + * reads them, and every test decides the values before the component is created. + */ +const createStoreMock = () => ({ + healthStatus: jest.fn().mockReturnValue(HealthStatusTypes.OK), + isMisconfigured: jest.fn().mockReturnValue(false), + pagedExperiments: jest.fn().mockReturnValue([] as DotExperiment[]), + pageInfoByPageId: jest.fn().mockReturnValue(PAGE_INFO), + statusCounts: jest.fn().mockReturnValue(EMPTY_STATUS_COUNTS), + selectedStatuses: jest.fn().mockReturnValue(DEFAULT_EXPERIMENTS_LIST_STATUSES), + goalCounts: jest.fn().mockReturnValue(EMPTY_GOAL_COUNTS), + selectedGoals: jest.fn().mockReturnValue(DEFAULT_EXPERIMENTS_LIST_GOALS), + filter: jest.fn().mockReturnValue(''), + status: jest.fn().mockReturnValue(ComponentStatus.LOADED), + page: jest.fn().mockReturnValue(DEFAULT_EXPERIMENTS_LIST_PAGE), + perPage: jest.fn().mockReturnValue(DEFAULT_EXPERIMENTS_LIST_PER_PAGE), + orderBy: jest.fn().mockReturnValue(DEFAULT_EXPERIMENTS_LIST_ORDER_BY), + direction: jest.fn().mockReturnValue(DEFAULT_EXPERIMENTS_LIST_DIRECTION), + totalRecords: jest.fn().mockReturnValue(0) +}); + describe('DotExperimentsListComponent', () => { let spectator: Spectator; - let store: DotExperimentsListStore; - let router: jest.Mocked; - let vmSubject: BehaviorSubject; + let storeMock: ReturnType; + let dispatch: jest.SpyInstance; + let confirm: jest.SpyInstance; const createComponent = createComponentFactory({ component: DotExperimentsListComponent, - imports: [DotExperimentsListComponent, MockComponent(DotAddToBundleComponent)], + // `componentProviders` replaces the component's own `providers`, so the real + // `ConfirmationService` has to be re-declared here (`p-confirmDialog` needs it). + componentProviders: [ + { provide: DotExperimentsListStore, useFactory: () => storeMock }, + ConfirmationService + ], providers: [ - provideHttpClient(), - provideHttpClientTesting(), - DotMessageService, - MessageService, - ConfirmationService, - DotHttpErrorManagerService, - mockProvider(DotExperimentsService), - mockProvider(DotPushPublishDialogService), - mockProvider(LoginService), - mockProvider(LoggerService), - mockProvider(DotFormatDateService), - mockProvider(DotcmsConfigService), - mockProvider(DotExperimentsStore, { - getPageId$: of('page-123'), - getPageTitle$: of('Test Page') - }), - mockProvider(Router, { - navigate: jest.fn().mockReturnValue(Promise.resolve(true)) - }), - mockProvider(ActivatedRoute, { - snapshot: { - params: { pageId: 'page-123' } - } - }) + provideRouter([{ path: 'experiments', children: [] }]), + provideLocationMocks(), + { provide: DotMessageService, useValue: messageServiceMock }, + mockProvider(DotMessageDisplayService), + mockProvider(DotPushPublishDialogService) ], detectChanges: false }); + /** Renders a single row of the given status and returns that experiment. */ + const renderRowWith = (status: DotExperimentStatus): DotExperiment => { + const experiment = experimentWith(status); + storeMock.pagedExperiments.mockReturnValue([experiment]); + storeMock.totalRecords.mockReturnValue(1); + spectator.detectChanges(); + + return experiment; + }; + + const clickButton = (testId: string) => { + const button = spectator.query(byTestId(testId))?.querySelector('button'); + spectator.click(button as HTMLElement); + spectator.detectChanges(); + }; + + /** Ids of the kebab entries the user can actually see for the currently rendered row. */ + const visibleMenuItemIds = (): string[] => { + clickButton('experiment-actions-btn'); + + return spectator.component + .$rowMenuItems() + .filter(({ visible }) => visible) + .map(({ id }) => id as string); + }; + + const runMenuItem = (itemId: string) => { + clickButton('experiment-actions-btn'); + const item = spectator.component + .$rowMenuItems() + .find(({ id }) => id === itemId) as MenuItem; + item.command?.({ originalEvent: new MouseEvent('click'), item }); + }; + + /** Accepts the confirmation opened by the last action and returns it. */ + const acceptConfirmation = (): Confirmation => { + const confirmation = confirm.mock.calls[0][0] as Confirmation; + confirmation.accept?.(); + + return confirmation; + }; + + /** `injectDispatch` appends a scope argument, so only the event itself is compared. */ + const dispatchedEvents = () => dispatch.mock.calls.map(([event]) => event); + + const emitSucceeded = ( + event: EventCreator, + experiment: DotExperiment + ) => { + spectator.inject(Dispatcher).dispatch(event(experiment)); + spectator.detectChanges(); + }; + beforeEach(() => { - vmSubject = new BehaviorSubject({ - experiments: [], - isLoading: false, - experimentsFiltered: [], - filterStatus: [ + storeMock = createStoreMock(); + spectator = createComponent(); + dispatch = jest.spyOn(spectator.inject(Dispatcher), 'dispatch'); + // `p-confirmDialog` needs the real service (it subscribes to its streams), so the + // confirmation is intercepted instead of mocked away. + const confirmationService = spectator.inject(ConfirmationService, true); + confirm = jest + .spyOn(confirmationService, 'confirm') + .mockReturnValue(confirmationService) as jest.SpyInstance; + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + describe('search', () => { + beforeEach(() => jest.useFakeTimers()); + afterEach(() => jest.useRealTimers()); + + const type = (text: string) => + spectator.typeInElement( + text, + spectator.query(byTestId('experiments-search-input')) as HTMLInputElement + ); + + it('should dispatch filterChanged only after the debounce window', async () => { + spectator.detectChanges(); + + type('summer'); + // `debounced` arms itself inside an internal effect that reads the source, so the + // timer is not even scheduled until a change-detection pass runs. + spectator.detectChanges(); + + expect(dispatchedEvents()).not.toContainEqual( + dotExperimentsListPageEvents.filterChanged('summer') + ); + + // It settles a Resource, so the timer alone is not enough — microtasks have to + // drain too, hence the async variant. The dispatch then lands in an effect on the + // next pass, rather than inside the timer callback as the rxjs version did. + await jest.advanceTimersByTimeAsync(SEARCH_DEBOUNCE_MS); + spectator.detectChanges(); + + expect(dispatchedEvents()).toContainEqual( + dotExperimentsListPageEvents.filterChanged('summer') + ); + }); + + it('should not re-dispatch when the settled term already matches the store', async () => { + // Stands in for `distinctUntilChanged`: re-typing the same text, or arriving with a + // hydrated `?filter=`, must not push a redundant filterChanged. + storeMock.filter.mockReturnValue('summer'); + spectator.detectChanges(); + + type('summer'); + spectator.detectChanges(); + await jest.advanceTimersByTimeAsync(SEARCH_DEBOUNCE_MS); + spectator.detectChanges(); + + expect(dispatchedEvents()).not.toContainEqual( + dotExperimentsListPageEvents.filterChanged('summer') + ); + }); + }); + + describe('status tag', () => { + // Severities mirror `DotExperimentsUiHeaderComponent` so a status looks the same here + // and in the UVE header. `warn` is PrimeNG's spelling; anything else renders unstyled. + it.each([ + [DotExperimentStatus.RUNNING, 'success', 'running'], + [DotExperimentStatus.SCHEDULED, 'info', 'scheduled'], + [DotExperimentStatus.DRAFT, 'warn', 'draft'], + [DotExperimentStatus.ENDED, 'info', 'ended'], + [DotExperimentStatus.ARCHIVED, 'secondary', 'archived'] + ])('should render %s as a %s tag', (status, severity, labelKey) => { + renderRowWith(status); + + const tag = spectator.query(byTestId('experiment-status-tag')); + + expect(tag?.className).toContain(`p-tag-${severity}`); + expect(tag?.textContent).toContain(messageServiceMock.get(labelKey)); + }); + }); + + describe('row actions', () => { + it.each([ + [ + DotExperimentStatus.DRAFT, + [MENU_ITEM.delete, MENU_ITEM.pushPublish, MENU_ITEM.addToBundle] + ], + [ DotExperimentStatus.RUNNING, + [MENU_ITEM.end, MENU_ITEM.abort, MENU_ITEM.pushPublish, MENU_ITEM.addToBundle] + ], + [ DotExperimentStatus.SCHEDULED, + [ + MENU_ITEM.cancelSchedule, + MENU_ITEM.delete, + MENU_ITEM.pushPublish, + MENU_ITEM.addToBundle + ] + ], + [ + DotExperimentStatus.ENDED, + [MENU_ITEM.archive, MENU_ITEM.pushPublish, MENU_ITEM.addToBundle] + ], + [ + DotExperimentStatus.ARCHIVED, + [MENU_ITEM.restore, MENU_ITEM.pushPublish, MENU_ITEM.addToBundle] + ] + ])('should only offer the actions allowed for %s', (status, expectedItemIds) => { + renderRowWith(status); + + expect(visibleMenuItemIds().sort()).toEqual([...expectedItemIds].sort()); + }); + + it.each(Object.values(DotExperimentStatus))( + 'should expose the kebab as the only control for %s', + (status) => { + // Every action lives in the menu, archive and restore included, so the cell + // holds exactly one button whatever the row's status. + renderRowWith(status); + + const labels = Array.from( + spectator + .query(byTestId('experiment-row')) + ?.querySelectorAll('td:last-child p-button[aria-label]') ?? [] + ).map((button) => button.getAttribute('aria-label')); + + expect(labels).toEqual([ACTIONS_MENU_LABEL]); + } + ); + + // One status per test: the store mock's signals are plain `jest.fn()`s, so a second + // `renderRowWith` in the same test would not recompute the row. + it('should offer archive in the kebab once the experiment has ended', () => { + renderRowWith(DotExperimentStatus.ENDED); + + expect(visibleMenuItemIds()).toContain(MENU_ITEM.archive); + }); + + it('should not offer archive while the experiment is still running', () => { + renderRowWith(DotExperimentStatus.RUNNING); + + expect(visibleMenuItemIds()).not.toContain(MENU_ITEM.archive); + }); + + it('should offer restore disabled on archived rows — no restore transition yet', () => { + renderRowWith(DotExperimentStatus.ARCHIVED); + clickButton('experiment-actions-btn'); + + const restore = spectator.component + .$rowMenuItems() + .find(({ id }) => id === MENU_ITEM.restore) as MenuItem; + + expect(restore.visible).toBe(true); + expect(restore.disabled).toBe(true); + expect(restore.command).toBeUndefined(); + }); + + it('should not render a configure or view-results control', () => { + // AC10: the Configure and Results screens land with #36990+. Until then the cell + // exposes only actions the row can actually perform — a disabled button that + // cannot navigate is noise. + Object.values(DotExperimentStatus).forEach((status) => { + renderRowWith(status); + + expect(spectator.query(byTestId('experiment-primary-action'))).toBeNull(); + }); + }); + + it('should never route into the legacy configure or results screens', () => { + Object.values(DotExperimentStatus).forEach((status) => { + renderRowWith(status); + + expect(visibleMenuItemIds()).not.toContain('experiments-configuration'); + expect(visibleMenuItemIds()).not.toContain('experiments-results'); + expect( + spectator.query(byTestId('experiment-row'))?.querySelector('a[href]') + ).toBeNull(); + }); + }); + + it('should offer a disabled new-experiment button', () => { + renderRowWith(DotExperimentStatus.DRAFT); + + const button = spectator.query(byTestId('experiments-new'))?.querySelector('button'); + + expect(button).not.toBeNull(); + expect((button as HTMLButtonElement).disabled).toBe(true); + }); + }); + + describe('confirm then dispatch', () => { + it('should dispatch archiveRequested once the archive confirmation is accepted', () => { + const experiment = renderRowWith(DotExperimentStatus.ENDED); + + runMenuItem(MENU_ITEM.archive); + + expect(dispatchedEvents()).not.toContainEqual( + dotExperimentsListPageEvents.archiveExperiment(experiment) + ); + + acceptConfirmation(); + + expect(dispatchedEvents()).toContainEqual( + dotExperimentsListPageEvents.archiveExperiment(experiment) + ); + }); + + it.each([ + [ DotExperimentStatus.DRAFT, - DotExperimentStatus.ENDED + MENU_ITEM.delete, + dotExperimentsListPageEvents.deleteExperiment ], - sidebar: { - status: ComponentStatus.IDLE, - isOpen: false - } as SidebarStatus, - pageId: 'page-123', - pageTitle: 'Test Page', - addToBundleContentId: null - }); - - const mockStore = { - vm$: vmSubject.asObservable(), - setFilterStatus: jest.fn(), - openSidebar: jest.fn(), - closeSidebar: jest.fn() - }; + [ + DotExperimentStatus.RUNNING, + MENU_ITEM.end, + dotExperimentsListPageEvents.endExperiment + ], + [ + DotExperimentStatus.RUNNING, + MENU_ITEM.abort, + dotExperimentsListPageEvents.abortExperiment + ], + [ + DotExperimentStatus.SCHEDULED, + MENU_ITEM.cancelSchedule, + dotExperimentsListPageEvents.cancelScheduleExperiment + ] + ])( + 'should confirm %s / %s before dispatching', + (status, itemId, expectedEvent: EventCreator) => { + const experiment = renderRowWith(status); + + runMenuItem(itemId); + + expect(confirm).toHaveBeenCalledTimes(1); + expect(dispatchedEvents()).not.toContainEqual(expectedEvent(experiment)); + + acceptConfirmation(); + + expect(dispatchedEvents()).toContainEqual(expectedEvent(experiment)); + } + ); + + it('should open the push publish dialog without a confirmation', () => { + const experiment = renderRowWith(DotExperimentStatus.DRAFT); + const pushPublishDialogService = spectator.inject(DotPushPublishDialogService, true); - spectator = createComponent({ - providers: [mockProvider(DotExperimentsListStore, mockStore)] + runMenuItem(MENU_ITEM.pushPublish); + + expect(pushPublishDialogService.open).toHaveBeenCalledWith( + expect.objectContaining({ assetIdentifier: experiment.id }) + ); }); - store = spectator.inject(DotExperimentsListStore, true); - router = spectator.inject(Router, true); + it('should open the add to bundle dialog without a confirmation', () => { + const experiment = renderRowWith(DotExperimentStatus.DRAFT); - spectator.detectChanges(); + runMenuItem(MENU_ITEM.addToBundle); + + expect(spectator.component.$addToBundleAssetId()).toBe(experiment.id); + }); }); - afterEach(() => { - jest.clearAllMocks(); + describe('success toasts', () => { + it.each([ + ['archived', dotExperimentsApiEvents.archiveSucceeded], + ['deleted', dotExperimentsApiEvents.deleteSucceeded], + ['ended', dotExperimentsApiEvents.endSucceeded], + ['aborted', dotExperimentsApiEvents.abortSucceeded], + ['unscheduled', dotExperimentsApiEvents.cancelScheduleSucceeded] + ])('should push a success toast once the experiment is %s', (expectedVerb, event) => { + const experiment = renderRowWith(DotExperimentStatus.DRAFT); + const messageDisplayService = spectator.inject(DotMessageDisplayService, true); + + emitSucceeded(event as EventCreator, experiment); + + expect(messageDisplayService.push).toHaveBeenCalledWith( + expect.objectContaining({ + severity: DotMessageSeverity.SUCCESS, + message: `Experiment ${experiment.name} ${expectedVerb}` + }) + ); + }); }); - it('should create', () => { - expect(spectator.component).toBeTruthy(); + describe('empty state', () => { + it('should render the empty state when there are no rows', () => { + spectator.detectChanges(); + + expect(spectator.query(byTestId('experiments-empty-state'))).not.toBeNull(); + expect(spectator.query(byTestId('experiment-row'))).toBeNull(); + }); + + it('should not render the empty state when there are rows', () => { + renderRowWith(DotExperimentStatus.DRAFT); + + expect(spectator.query(byTestId('experiments-empty-state'))).toBeNull(); + }); }); - describe('template - loading state', () => { - it('should show the skeleton component when is loading', () => { - // Setup: Loading state - vmSubject.next({ - experiments: [], - isLoading: true, - experimentsFiltered: [], - filterStatus: [DotExperimentStatus.DRAFT], - sidebar: { status: ComponentStatus.LOADING, isOpen: false }, - pageId: 'page-123', - pageTitle: 'Test Page', - addToBundleContentId: null - }); + describe('loading state', () => { + it('should render skeleton rows while the list is loading', () => { + storeMock.status.mockReturnValue(ComponentStatus.LOADING); + spectator.detectChanges(); + + expect(spectator.queryAll(byTestId('experiments-loading-row')).length).toBeGreaterThan( + 0 + ); + expect(spectator.query(byTestId('experiment-row'))).toBeNull(); + }); + it('should not show the empty state while loading', () => { + // Otherwise a slow load momentarily claims there are no experiments. + storeMock.status.mockReturnValue(ComponentStatus.LOADING); spectator.detectChanges(); - const skeleton = spectator.query('dot-experiments-list-skeleton'); - const emptyContainer = spectator.query('dot-empty-container'); - const tableComponent = spectator.query('dot-experiments-list-table'); + expect(spectator.query(byTestId('experiments-empty-state'))).toBeNull(); + }); + + it('should keep showing rows while a reload is in flight', () => { + // Paging and filtering re-enter 'loading' with rows already on screen; replacing + // them with skeletons on every keystroke would make the table flicker. + const experiment = renderRowWith(DotExperimentStatus.DRAFT); + storeMock.status.mockReturnValue(ComponentStatus.LOADING); + spectator.detectChanges(); - expect(skeleton).toBeTruthy(); - expect(emptyContainer).toBeNull(); - expect(tableComponent).toBeNull(); + expect(spectator.query(byTestId('experiment-name'))?.textContent).toContain( + experiment.name + ); }); }); - describe('template - empty state', () => { - it('should show the empty component when is not loading and no experiments', () => { - // Setup: Empty state - vmSubject.next({ - experiments: [], - isLoading: false, - experimentsFiltered: [], - filterStatus: [DotExperimentStatus.DRAFT], - sidebar: { status: ComponentStatus.IDLE, isOpen: false }, - pageId: 'page-123', - pageTitle: 'Test Page', - addToBundleContentId: null - }); - + describe('load error', () => { + const renderError = () => { + storeMock.status.mockReturnValue(ComponentStatus.ERROR); spectator.detectChanges(); + }; + + it('should render the error state instead of the table', () => { + // A failed load must not read as "no experiments" — the distinction matters, since + // an empty table invites the user to create one that may already exist. + renderError(); + + const error = spectator.query(byTestId('experiments-error')); + + expect(error).not.toBeNull(); + expect(error?.textContent).toContain(ERROR_COPY.title); + expect(error?.textContent).toContain(ERROR_COPY.subtitle); + expect(spectator.query(byTestId('experiments-table-wrapper'))).toBeNull(); + expect(spectator.query(byTestId('experiments-empty-state'))).toBeNull(); + }); - const skeleton = spectator.query('dot-experiments-list-skeleton'); - const emptyContainer = spectator.query('dot-empty-container'); - const tableComponent = spectator.query('dot-experiments-list-table'); + it('should re-run the health gate when retry is pressed, not just the list', () => { + renderError(); - expect(skeleton).toBeNull(); - expect(emptyContainer).toBeTruthy(); - expect(tableComponent).toBeNull(); + clickButton('experiments-error'); - // Verify empty container configuration - expect(emptyContainer?.textContent).toContain('experimentspage.not.experiments.founds'); + // Requesting the list alone would fetch experiments the gate never cleared, and + // `$isLoading` keys off a null healthStatus — so the table would sit on skeletons + // even after the list came back. + expect(dispatchedEvents()).toContainEqual(dotExperimentsListPageEvents.checkHealth()); + expect(dispatchedEvents()).not.toContainEqual( + dotExperimentsListPageEvents.loadExperiments() + ); + }); + + it('should not render the error state on a healthy load', () => { + spectator.detectChanges(); + + expect(spectator.query(byTestId('experiments-error'))).toBeNull(); }); }); - describe('template - experiments list', () => { - it('should show the filters component and add experiment button exist when has experiments', () => { - // Setup: Has experiments - const experimentWithActions: DotExperimentsWithActions = { - ...EXPERIMENT_MOCK_DRAFT, - actionsItemsMenu: [] - }; - vmSubject.next({ - experiments: [EXPERIMENT_MOCK_DRAFT], - isLoading: false, - experimentsFiltered: [ - { - status: DotExperimentStatus.DRAFT, - experiments: [experimentWithActions] - } - ], - filterStatus: [DotExperimentStatus.DRAFT], - sidebar: { status: ComponentStatus.IDLE, isOpen: false }, - pageId: 'page-123', - pageTitle: 'Test Page', - addToBundleContentId: null - }); + describe('analytics misconfiguration', () => { + const renderMisconfigured = (healthStatus: HealthStatusTypes) => { + storeMock.isMisconfigured.mockReturnValue(true); + storeMock.healthStatus.mockReturnValue(healthStatus); + spectator.detectChanges(); + }; + it.each([ + [HealthStatusTypes.NOT_CONFIGURED, NOT_CONFIGURED_COPY], + [HealthStatusTypes.CONFIGURATION_ERROR, MISCONFIGURATION_COPY] + ])('should render the %s message', (healthStatus, copy) => { + renderMisconfigured(healthStatus); + + const container = spectator.query(byTestId('experiments-misconfiguration')); + + expect(container?.textContent).toContain(copy.title); + expect(container?.textContent).toContain(copy.subtitle); + }); + + it('should show the loading skeleton while the health check is still in flight', () => { + // The gate counts as loading: the skeleton is on screen from the first paint and + // resolves seamlessly into rows, rather than a blank shell that then fills in. + storeMock.isMisconfigured.mockReturnValue(false); + storeMock.healthStatus.mockReturnValue(null); spectator.detectChanges(); - const filterComponent = spectator.query('dot-experiments-status-filter'); - const addButton = spectator.query('[data-testId="add-experiment-button"]'); - const tableComponent = spectator.query('dot-experiments-list-table'); - const emptyContainer = spectator.query('dot-empty-container'); - const skeleton = spectator.query('dot-experiments-list-skeleton'); + expect(spectator.queryAll(byTestId('experiments-loading-row')).length).toBeGreaterThan( + 0 + ); + expect(spectator.query(byTestId('experiments-misconfiguration'))).toBeNull(); + expect(spectator.query(byTestId('experiments-empty-state'))).toBeNull(); + }); + + it('should hide the toolbar, the filters and the table', () => { + renderMisconfigured(HealthStatusTypes.NOT_CONFIGURED); + + expect(spectator.query(byTestId('experiments-search-input'))).toBeNull(); + expect(spectator.query(byTestId('experiments-status-filter'))).toBeNull(); + expect(spectator.query(byTestId('experiments-goal-filter'))).toBeNull(); + expect(spectator.query(byTestId('experiments-table-wrapper'))).toBeNull(); + expect(spectator.query(byTestId('experiments-table'))).toBeNull(); + expect(spectator.query(byTestId('experiments-empty-state'))).toBeNull(); + }); + + it('should render the list untouched when analytics is healthy', () => { + renderRowWith(DotExperimentStatus.DRAFT); + + expect(spectator.query(byTestId('experiments-misconfiguration'))).toBeNull(); + expect(spectator.query(byTestId('experiments-table'))).not.toBeNull(); + expect(spectator.query(byTestId('experiment-row'))).not.toBeNull(); + }); + }); + + describe('column widths', () => { + it('should lay the table out fixed so widths do not follow the visible rows', () => { + // Regression: with the default `auto` layout the browser sizes each column to its + // content, so filtering to a status whose rows carry no date range (Draft shows + // "Not scheduled") collapsed the Schedule column and shifted every column after it. + renderRowWith(DotExperimentStatus.DRAFT); + + const table = spectator.query('table.p-datatable-table') as HTMLTableElement; - expect(filterComponent).toBeTruthy(); - expect(addButton).toBeTruthy(); - expect(tableComponent).toBeTruthy(); - expect(emptyContainer).toBeNull(); - expect(skeleton).toBeNull(); + expect(table).not.toBeNull(); + expect(table.style.tableLayout).toBe('fixed'); }); }); - describe('sidebar interactions', () => { - it('should show the sidebar when click ADD EXPERIMENT', fakeAsync(() => { - // Setup: Has experiments - const experimentWithActions: DotExperimentsWithActions = { - ...EXPERIMENT_MOCK_DRAFT, - actionsItemsMenu: [] - }; - vmSubject.next({ - experiments: [EXPERIMENT_MOCK_DRAFT], - isLoading: false, - experimentsFiltered: [ - { - status: DotExperimentStatus.DRAFT, - experiments: [experimentWithActions] - } - ], - filterStatus: [DotExperimentStatus.DRAFT], - sidebar: { status: ComponentStatus.IDLE, isOpen: false }, - pageId: 'page-123', - pageTitle: 'Test Page', - addToBundleContentId: null + describe('filters', () => { + it('should render a chip for both status and goal', () => { + renderRowWith(DotExperimentStatus.DRAFT); + + expect(spectator.query(byTestId('experiments-status-filter'))).not.toBeNull(); + expect(spectator.query(byTestId('experiments-goal-filter'))).not.toBeNull(); + }); + + it('should offer one option per goal, counted', () => { + storeMock.goalCounts.mockReturnValue({ + ...EMPTY_GOAL_COUNTS, + [GOAL_TYPES.BOUNCE_RATE]: 4 }); + renderRowWith(DotExperimentStatus.DRAFT); + + const options = spectator.component['$goalFilterOptions'](); + + expect(options.length).toBe(Object.values(GOAL_TYPES).length); + expect(options.find(({ value }) => value === GOAL_TYPES.BOUNCE_RATE)?.count).toBe('4'); + }); + + it('should dispatch goalsChanged with the picked goals', () => { + renderRowWith(DotExperimentStatus.DRAFT); + + spectator.component.onGoalsChange([GOAL_TYPES.EXIT_RATE]); + + expect(dispatchedEvents()).toContainEqual( + dotExperimentsListPageEvents.goalsChanged([GOAL_TYPES.EXIT_RATE]) + ); + }); + }); + + describe('search clear', () => { + beforeEach(() => { + jest.useFakeTimers(); + // The component is created without an initial render, so the toolbar has to be + // rendered before anything can be typed into it. + spectator.detectChanges(); + }); + afterEach(() => jest.useRealTimers()); + + const searchInput = () => + spectator.query(byTestId('experiments-search-input')) as HTMLInputElement; + + it('should not offer a clear control while the box is empty', () => { + expect(spectator.query(byTestId('experiments-search-clear'))).toBeNull(); + }); + + it('should offer a clear control once something is typed', () => { + spectator.typeInElement('alpha', searchInput()); + spectator.detectChanges(); + + expect(spectator.query(byTestId('experiments-search-clear'))).not.toBeNull(); + }); + + it('should empty the box and hide itself when clicked', async () => { + spectator.typeInElement('alpha', searchInput()); + spectator.detectChanges(); + + spectator.click(spectator.query(byTestId('experiments-search-clear')) as HTMLElement); + spectator.detectChanges(); + + // `NgModel` pushes the model back to the input on a microtask, so the DOM value is + // one tick behind the signal. + await jest.advanceTimersByTimeAsync(0); + spectator.detectChanges(); + + expect(searchInput().value).toBe(''); + expect(spectator.query(byTestId('experiments-search-clear'))).toBeNull(); + }); + it('should dispatch the emptied term after the debounce window', async () => { + // Arrive at "a term is applied": type it, then let the store report it as applied. + // The mock's signals are plain functions, so the term has to be typed rather than + // seeded through `filter` — a linkedSignal would never recompute from it. + spectator.typeInElement('alpha', searchInput()); + spectator.detectChanges(); + storeMock.filter.mockReturnValue('alpha'); + await jest.advanceTimersByTimeAsync(SEARCH_DEBOUNCE_MS); spectator.detectChanges(); - const addButton = spectator.query('[data-testId="add-experiment-button"]'); - expect(addButton).toBeTruthy(); + spectator.click(spectator.query(byTestId('experiments-search-clear')) as HTMLElement); + spectator.detectChanges(); - // Action: Click add experiment button - spectator.click(addButton as Element); - tick(); + // Clearing writes the same signal typing does, so it settles through the debounce + // rather than dispatching straight away. + await jest.advanceTimersByTimeAsync(SEARCH_DEBOUNCE_MS); + spectator.detectChanges(); - // Verify: Store method was called to open sidebar - expect(store.openSidebar).toHaveBeenCalled(); - })); + expect(dispatchedEvents()).toContainEqual( + dotExperimentsListPageEvents.filterChanged('') + ); + }); }); - describe('navigation based on experiment status', () => { - it('should go to report Container if the experiment status is RUNNING', () => { - spectator.component.goToContainerAction(EXPERIMENT_MOCK_RUNNING); + describe('paginator', () => { + it('should render the content-drive paginator shape: a page report, prev and next only', () => { + renderRowWith(DotExperimentStatus.DRAFT); - expect(router.navigate).toHaveBeenCalledWith( - [ - '/edit-page/experiments/', - EXPERIMENT_MOCK_RUNNING.pageId, - EXPERIMENT_MOCK_RUNNING.id, - 'reports' - ], - { - queryParams: { - mode: null, - variantName: null, - experimentId: null - }, - queryParamsHandling: 'merge' - } + const paginator = spectator.query('p-paginator'); + + expect(paginator?.querySelector('.p-paginator-current')?.textContent).toContain( + 'Page 1' ); + // First/last jumps and the numbered page links are both off, as in content-drive. + expect(paginator?.querySelector('.p-paginator-first')).toBeNull(); + expect(paginator?.querySelector('.p-paginator-last')).toBeNull(); + expect(paginator?.querySelector('.p-paginator-pages')).toBeNull(); + expect(paginator?.querySelector('.p-paginator-prev')).not.toBeNull(); + expect(paginator?.querySelector('.p-paginator-next')).not.toBeNull(); }); + }); - it('should go to report Container if the experiment status is ENDED', () => { - spectator.component.goToContainerAction(EXPERIMENT_MOCK_ENDED); + describe('table events', () => { + it('should translate a lazy-load offset into a 1-based page', () => { + renderRowWith(DotExperimentStatus.DRAFT); - expect(router.navigate).toHaveBeenCalledWith( - [ - '/edit-page/experiments/', - EXPERIMENT_MOCK_ENDED.pageId, - EXPERIMENT_MOCK_ENDED.id, - 'reports' - ], - { - queryParams: { - mode: null, - variantName: null, - experimentId: null - }, - queryParamsHandling: 'merge' - } + spectator.component.onLazyLoad({ first: 50, rows: 25 }); + + expect(dispatchedEvents()).toContainEqual( + dotExperimentsListPageEvents.pageChanged({ page: 3, perPage: 25 }) ); }); - it('should go to configuration Container if the experiment status is DRAFT', () => { - spectator.component.goToContainerAction(EXPERIMENT_MOCK_DRAFT); + it('should fall back to the store paging when the event omits it', () => { + renderRowWith(DotExperimentStatus.DRAFT); - expect(router.navigate).toHaveBeenCalledWith( - [ - '/edit-page/experiments/', - EXPERIMENT_MOCK_DRAFT.pageId, - EXPERIMENT_MOCK_DRAFT.id, - 'configuration' - ], - { - queryParams: { - mode: null, - variantName: null, - experimentId: null - }, - queryParamsHandling: 'merge' - } + spectator.component.onLazyLoad({}); + + expect(dispatchedEvents()).toContainEqual( + dotExperimentsListPageEvents.pageChanged({ + page: 1, + perPage: DEFAULT_EXPERIMENTS_LIST_PER_PAGE + }) ); }); - it('should go to configuration Container if the experiment status is SCHEDULED', () => { - spectator.component.goToContainerAction(EXPERIMENT_MOCK_SCHEDULED); + it('should map the sort order onto a direction', () => { + renderRowWith(DotExperimentStatus.DRAFT); - expect(router.navigate).toHaveBeenCalledWith( - [ - '/edit-page/experiments/', - EXPERIMENT_MOCK_SCHEDULED.pageId, - EXPERIMENT_MOCK_SCHEDULED.id, - 'configuration' - ], - { - queryParams: { - mode: null, - variantName: null, - experimentId: null - }, - queryParamsHandling: 'merge' - } + spectator.component.onLazyLoad({ + first: 0, + rows: 25, + sortField: 'name', + sortOrder: -1 + }); + + expect(dispatchedEvents()).toContainEqual( + dotExperimentsListPageEvents.sortChanged({ orderBy: 'name', direction: 'DESC' }) ); }); - }); - describe('add to bundle dialog', () => { - it('should show and remove add to bundle dialog', () => { - // Setup: Show add to bundle dialog - const experimentWithActions: DotExperimentsWithActions = { - ...EXPERIMENT_MOCK_DRAFT, - actionsItemsMenu: [] - }; - vmSubject.next({ - experiments: [EXPERIMENT_MOCK_DRAFT], - isLoading: false, - experimentsFiltered: [ - { - status: DotExperimentStatus.DRAFT, - experiments: [experimentWithActions] - } - ], - filterStatus: [DotExperimentStatus.DRAFT], - sidebar: { status: ComponentStatus.IDLE, isOpen: false }, - pageId: 'page-123', - pageTitle: 'Test Page', - addToBundleContentId: 'experiment-123' + it('should take the first field when the table reports an array', () => { + renderRowWith(DotExperimentStatus.DRAFT); + + spectator.component.onLazyLoad({ + first: 0, + rows: 25, + sortField: ['status', 'name'], + sortOrder: 1 }); - spectator.detectChanges(); + expect(dispatchedEvents()).toContainEqual( + dotExperimentsListPageEvents.sortChanged({ orderBy: 'status', direction: 'ASC' }) + ); + }); + + it('should not dispatch a sort when the event carries no field', () => { + renderRowWith(DotExperimentStatus.DRAFT); + // Rendering the table fires its own lazy load, and that one does carry the current + // sort field — so only what happens after this point is under test. + dispatch.mockClear(); + + spectator.component.onLazyLoad({ first: 0, rows: 25 }); + + expect(dispatchedEvents().some(({ type }) => type.includes('sortChanged'))).toBe(false); + }); - // Verify: Add to bundle dialog is shown - let addToBundleComponent = spectator.query('dot-add-to-bundle'); - expect(addToBundleComponent).toBeTruthy(); - - // Setup: Remove add to bundle dialog - vmSubject.next({ - experiments: [EXPERIMENT_MOCK_DRAFT], - isLoading: false, - experimentsFiltered: [ - { - status: DotExperimentStatus.DRAFT, - experiments: [experimentWithActions] - } - ], - filterStatus: [DotExperimentStatus.DRAFT], - sidebar: { status: ComponentStatus.IDLE, isOpen: false }, - pageId: 'page-123', - pageTitle: 'Test Page', - addToBundleContentId: null + it('should not dispatch a sort when the event only carries the sort already applied', () => { + renderRowWith(DotExperimentStatus.DRAFT); + dispatch.mockClear(); + + // What PrimeNG actually emits when you click page 2: `createLazyLoadMetadata()` puts + // the *current* sortField and sortOrder on every lazy-load event, pagination included. + spectator.component.onLazyLoad({ + first: 25, + rows: 25, + sortField: DEFAULT_EXPERIMENTS_LIST_ORDER_BY, + sortOrder: -1 }); - spectator.detectChanges(); + // Dispatching that no-op sort resets the page, so paging never advanced past 1 and a + // `?page=N` deep link snapped back on load. + expect(dispatchedEvents()).toContainEqual( + dotExperimentsListPageEvents.pageChanged({ page: 2, perPage: 25 }) + ); + expect(dispatchedEvents().some(({ type }) => type.includes('sortChanged'))).toBe(false); + }); + + it('should still dispatch a sort when the direction actually changes', () => { + renderRowWith(DotExperimentStatus.DRAFT); + dispatch.mockClear(); - // Verify: Add to bundle dialog is removed - addToBundleComponent = spectator.query('dot-add-to-bundle'); - expect(addToBundleComponent).toBeNull(); + spectator.component.onLazyLoad({ + first: 0, + rows: 25, + sortField: DEFAULT_EXPERIMENTS_LIST_ORDER_BY, + sortOrder: 1 + }); + + expect(dispatchedEvents()).toContainEqual( + dotExperimentsListPageEvents.sortChanged({ + orderBy: DEFAULT_EXPERIMENTS_LIST_ORDER_BY, + direction: 'ASC' + }) + ); }); - }); - describe('filter interactions', () => { - it('should call store method when filter is changed', () => { - const newFilterStatus = [DotExperimentStatus.RUNNING, DotExperimentStatus.ENDED]; + it('should dispatch statusesChanged with the picked statuses', () => { + renderRowWith(DotExperimentStatus.DRAFT); - spectator.component.selectedStatusFilter(newFilterStatus); + spectator.component.onStatusesChange([DotExperimentStatus.ENDED]); - expect(store.setFilterStatus).toHaveBeenCalledWith(newFilterStatus); + expect(dispatchedEvents()).toContainEqual( + dotExperimentsListPageEvents.statusesChanged([DotExperimentStatus.ENDED]) + ); }); }); - describe('navigation - back button', () => { - it('should navigate to edit page content when goToBrowserBack is called', () => { - spectator.component.goToBrowserBack(); - - expect(router.navigate).toHaveBeenCalledWith(['edit-page/content'], { - queryParams: { - mode: null, - variantName: null, - experimentId: null - }, - queryParamsHandling: 'merge' - }); + describe('empty states', () => { + const renderEmpty = () => { + storeMock.pagedExperiments.mockReturnValue([]); + storeMock.totalRecords.mockReturnValue(0); + spectator.detectChanges(); + }; + + const emptyTitle = () => + spectator.query(byTestId('experiments-empty-state'))?.textContent ?? ''; + + it('should replace the table so the message can centre in the space it leaves', () => { + renderEmpty(); + + // Rendered inside the table the message sat in a short band under the header, with + // the table's bottom border cutting across it. + expect(spectator.query(byTestId('experiments-empty-state'))).not.toBeNull(); + expect(spectator.query(byTestId('experiments-table'))).toBeNull(); + }); + + it('should say the site has none when nothing is filtered', () => { + renderEmpty(); + + expect(emptyTitle()).toContain('experiments.list.empty.title'); + expect(spectator.query(byTestId('experiments-empty-state'))?.textContent).not.toContain( + 'experiments.list.no-results.clear' + ); + }); + + it('should say nothing matched when a status is selected', () => { + storeMock.selectedStatuses.mockReturnValue([DotExperimentStatus.SCHEDULED]); + renderEmpty(); + + // The site may well have experiments; these filters are hiding them. + expect(emptyTitle()).toContain('experiments.list.no-results.title'); + }); + + it('should say nothing matched when only a search term is set', () => { + storeMock.filter.mockReturnValue('nothing-matches-this'); + renderEmpty(); + + expect(emptyTitle()).toContain('experiments.list.no-results.title'); + }); + + it('should say nothing matched when only a goal is selected', () => { + storeMock.selectedGoals.mockReturnValue([GOAL_TYPES.BOUNCE_RATE]); + renderEmpty(); + + expect(emptyTitle()).toContain('experiments.list.no-results.title'); + }); + + it('should offer a way out of the filtered empty state', () => { + storeMock.selectedStatuses.mockReturnValue([DotExperimentStatus.SCHEDULED]); + storeMock.selectedGoals.mockReturnValue([GOAL_TYPES.EXIT_RATE]); + renderEmpty(); + + spectator.component.onClearFilters(); + + expect(dispatchedEvents()).toContainEqual( + dotExperimentsListPageEvents.statusesChanged([]) + ); + expect(dispatchedEvents()).toContainEqual( + dotExperimentsListPageEvents.goalsChanged([]) + ); + }); + + it('should not show an empty state while the first load is still out', () => { + storeMock.healthStatus.mockReturnValue(null); + renderEmpty(); + + // Skeletons, not "no experiments" — the answer is not in yet. + expect(spectator.query(byTestId('experiments-empty-state'))).toBeNull(); }); }); }); diff --git a/core-web/libs/portlets/dot-experiments/portlet/src/lib/dot-experiments-list/dot-experiments-list.component.ts b/core-web/libs/portlets/dot-experiments/portlet/src/lib/dot-experiments-list/dot-experiments-list.component.ts index 8b10007707d4..c7acf36e101e 100644 --- a/core-web/libs/portlets/dot-experiments/portlet/src/lib/dot-experiments-list/dot-experiments-list.component.ts +++ b/core-web/libs/portlets/dot-experiments/portlet/src/lib/dot-experiments-list/dot-experiments-list.component.ts @@ -1,170 +1,550 @@ -import { provideComponentStore } from '@ngrx/component-store'; -import { Observable } from 'rxjs'; +import { EventCreator, Events, injectDispatch } from '@ngrx/signals/events'; -import { AsyncPipe, NgTemplateOutlet } from '@angular/common'; -import { ChangeDetectionStrategy, Component, ComponentRef, inject, viewChild } from '@angular/core'; -import { Router } from '@angular/router'; +import { DatePipe } from '@angular/common'; +import { + Component, + computed, + debounced, + DestroyRef, + effect, + inject, + linkedSignal, + signal, + untracked +} from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { FormsModule } from '@angular/forms'; +import { ConfirmationService, MenuItem } from 'primeng/api'; import { ButtonModule } from 'primeng/button'; import { ConfirmDialogModule } from 'primeng/confirmdialog'; +import { IconFieldModule } from 'primeng/iconfield'; +import { InputIconModule } from 'primeng/inputicon'; +import { InputTextModule } from 'primeng/inputtext'; +import { MenuModule } from 'primeng/menu'; +import { SkeletonModule } from 'primeng/skeleton'; +import { TableLazyLoadEvent, TableModule } from 'primeng/table'; +import { TagModule } from 'primeng/tag'; +import { ToolbarModule } from 'primeng/toolbar'; +import { TooltipModule } from 'primeng/tooltip'; -import { tap } from 'rxjs/operators'; - -import { DotMessageService } from '@dotcms/data-access'; +import { + DotExperimentsService, + DotMessageDisplayService, + DotMessageService +} from '@dotcms/data-access'; +import { DotPushPublishDialogService } from '@dotcms/dotcms-js'; import { ComponentStatus, CONFIGURATION_CONFIRM_DIALOG_KEY, DotExperiment, DotExperimentStatus, ExperimentsStatusList, - SidebarStatus + GOAL_TYPES, + DotMessageSeverity, + DotMessageType, + GOALS_METADATA_MAP, + HealthStatusTypes } from '@dotcms/dotcms-models'; import { DotAddToBundleComponent, - DotDynamicDirective, DotEmptyContainerComponent, DotMessagePipe, PrincipalConfiguration } from '@dotcms/ui'; -import { DotExperimentsCreateComponent } from './components/dot-experiments-create/dot-experiments-create.component'; -import { DotExperimentsListSkeletonComponent } from './components/dot-experiments-list-skeleton/dot-experiments-list-skeleton.component'; -import { DotExperimentsListTableComponent } from './components/dot-experiments-list-table/dot-experiments-list-table.component'; -import { DotExperimentsStatusFilterComponent } from './components/dot-experiments-status-filter/dot-experiments-status-filter.component'; -import { DotExperimentsListStore, VmListExperiments } from './store/dot-experiments-list-store'; - -import { DotExperimentsUiHeaderComponent } from '../shared/ui/dot-experiments-header/dot-experiments-ui-header.component'; +import { DotExperimentListFilterComponent } from '../components/dot-experiment-list-filter/dot-experiment-list-filter.component'; +import { + GOAL_LABEL_KEYS, + NO_GOAL_PLACEHOLDER, + ROWS_PER_PAGE_OPTIONS, + SEARCH_DEBOUNCE_MS, + SKELETON_COLUMNS, + SKELETON_ROWS, + STATUS_LABEL_KEYS, + STATUS_SEVERITIES, + SUCCESS_MESSAGE_LIFE +} from '../shared/constants'; +import { + DotExperimentsListSortDirection, + ExperimentFilterOption, + ExperimentRow +} from '../shared/models'; +import { dotExperimentsApiEvents } from '../store/dot-experiments-api.events'; +import { dotExperimentsListPageEvents } from '../store/dot-experiments-list-page.events'; +import { DotExperimentsListStore } from '../store/dot-experiments-list.store'; +import { + ExperimentScheduleLabels, + formatSchedule, + goalTypeOf, + isAllowed, + resolvePagePath, + variantsCount +} from '../util/dot-experiments-list.util'; @Component({ selector: 'dot-experiments-list', imports: [ - AsyncPipe, - NgTemplateOutlet, - DotExperimentsListSkeletonComponent, - DotExperimentsStatusFilterComponent, - DotExperimentsListTableComponent, - DotExperimentsUiHeaderComponent, - DotDynamicDirective, - DotMessagePipe, + DatePipe, + FormsModule, ButtonModule, ConfirmDialogModule, + IconFieldModule, + InputIconModule, + InputTextModule, + MenuModule, + SkeletonModule, + TableModule, + TagModule, + ToolbarModule, + TooltipModule, DotAddToBundleComponent, - DotEmptyContainerComponent + DotEmptyContainerComponent, + DotExperimentListFilterComponent, + DotMessagePipe ], templateUrl: './dot-experiments-list.component.html', - providers: [provideComponentStore(DotExperimentsListStore)], - changeDetection: ChangeDetectionStrategy.OnPush, - host: { - class: 'h-full w-full flex flex-col pb-12' - } + styleUrls: ['./dot-experiments-list.component.scss'], + // `DotExperimentsService` is `@Injectable()` with no `providedIn` and is not in the app-wide + // `providers.ts`, so the store cannot inject it unless this component provides it. The legacy + // screens do the same in `old/dot-experiments-shell`. + providers: [DotExperimentsListStore, ConfirmationService, DotExperimentsService], + host: { class: 'flex flex-col h-full min-h-0' } }) export class DotExperimentsListComponent { - private readonly dotExperimentsListStore = inject(DotExperimentsListStore); - private readonly router = inject(Router); - private readonly dotMessageService = inject(DotMessageService); + readonly store = inject(DotExperimentsListStore); + + readonly CONFIRM_KEY = CONFIGURATION_CONFIRM_DIALOG_KEY; + readonly NO_GOAL_PLACEHOLDER = NO_GOAL_PLACEHOLDER; + readonly ROWS_PER_PAGE_OPTIONS = ROWS_PER_PAGE_OPTIONS; + readonly SKELETON_COLUMNS = SKELETON_COLUMNS; + + /** Rows currently rendered by the table, already resolved for display. */ + readonly $rows = computed(() => { + const pageInfoByPageId = this.store.pageInfoByPageId(); + const scheduleLabels = this.#scheduleLabels; + + return this.store.pagedExperiments().map((experiment) => { + const goalType = goalTypeOf(experiment.goals); + + return { + experiment, + pagePath: resolvePagePath(experiment.pageId, pageInfoByPageId), + goalLabelKey: goalType ? GOALS_METADATA_MAP[goalType].label : null, + variants: variantsCount(experiment.trafficProportion), + schedule: formatSchedule(experiment.scheduling, scheduleLabels), + statusSeverity: STATUS_SEVERITIES[experiment.status] ?? 'secondary', + statusLabelKey: STATUS_LABEL_KEYS.get(experiment.status) ?? '' + }; + }); + }); + + /** + * What the table renders. During the very first load there is nothing to show yet, so the + * value is padded with placeholders and the body template swaps every cell for a skeleton. + */ + readonly $tableValue = computed(() => { + const rows = this.$rows(); + + return this.$isLoading() && rows.length === 0 ? SKELETON_ROWS : rows; + }); - sidebarHost = viewChild.required(DotDynamicDirective); - vm$: Observable = this.dotExperimentsListStore.vm$.pipe( - tap(({ sidebar }) => this.handleSidebar(sidebar)) + /** + * The screen is loading while the Analytics health check is still out (`healthStatus` null) + * as well as while the list itself is in flight. + * + * Folding the two together means the skeleton is on screen from the first paint instead of + * a blank shell, and the skeleton → rows transition is seamless because both render the + * same table. The trade-off is a swap when the gate comes back misconfigured — acceptable, + * since loading happens on every entry and a broken Analytics install does not. + */ + readonly $isLoading = computed( + () => this.store.healthStatus() === null || this.store.status() === ComponentStatus.LOADING ); - statusOptionList = ExperimentsStatusList; - confirmDialogKey = CONFIGURATION_CONFIRM_DIALOG_KEY; - protected readonly emptyConfiguration: PrincipalConfiguration = { - title: this.dotMessageService.get('experimentspage.not.experiments.founds'), - icon: 'pi-filter-fill rotate-180' + /** Only a failed load reaches this; a failed CRUD action returns the store to `LOADED`. */ + readonly $hasError = computed(() => this.store.status() === ComponentStatus.ERROR); + + /** + * Copy shown instead of the list when Analytics is not usable. Mirrors the legacy + * misconfiguration screen: only `NOT_CONFIGURED` means "never set up", every other + * non-OK status is a broken configuration. Genuinely reactive — it reads `healthStatus`. + */ + readonly $misconfiguredConfiguration = computed(() => { + const isNotConfigured = this.store.healthStatus() === HealthStatusTypes.NOT_CONFIGURED; + + return { + title: this.#dotMessageService.get( + isNotConfigured + ? 'experiments.analytics-app-no-configured.title' + : 'experiments.analytics-app-misconfiguration.title' + ), + subtitle: this.#dotMessageService.get( + isNotConfigured + ? 'experiments.analytics-app-no-configured.subtitle' + : 'experiments.analytics-app-misconfiguration.subtitle' + ), + // Material symbols, matching the empty state above — the legacy screen used a + // PrimeIcon, which would be the only one on this page. + icon: 'analytics', + iconStyle: 'material-symbols-rounded' + }; + }); + + /** Actions of the row whose kebab menu is open; rebuilt on every toggle. */ + readonly $rowMenuItems = signal([]); + + /** Identifier of the experiment being added to a bundle, or `null` when the dialog is closed. */ + readonly $addToBundleAssetId = signal(null); + + // The page dispatches only page events; `…Succeeded` / `…Failed` are the API's to raise, and + // are listened to (never dispatched) here — see `#listenForActionSuccess`. + readonly #dispatch = injectDispatch(dotExperimentsListPageEvents); + readonly #events = inject(Events); + readonly #confirmationService = inject(ConfirmationService); + readonly #dotMessageService = inject(DotMessageService); + readonly #dotMessageDisplayService = inject(DotMessageDisplayService); + readonly #pushPublishDialogService = inject(DotPushPublishDialogService); + readonly #destroyRef = inject(DestroyRef); + + /** + * Raw search-box text. A `linkedSignal` rather than a plain one so it re-seeds from the store + * whenever the filter changes underneath it — URL hydration on entry, and back/forward — while + * staying writable by the input. + */ + readonly $searchTerm = linkedSignal(() => this.store.filter()); + + /** + * Debounced view of the search box, using Angular's `debounced` rather than an rxjs + * `Subject` + `debounceTime`. Experimental in 22.0, so it may change shape. + */ + readonly #debouncedSearch = debounced(() => this.$searchTerm(), SEARCH_DEBOUNCE_MS); + + /** + * Translated fallbacks handed to `formatSchedule`, which stays free of user-facing English. + * + * Resolved once, not `computed`: `DotMessageService.get` is a plain lookup with no signal + * behind it, so a computed would memoise on first read and never recompute — reactivity it + * does not have. Messages are loaded before the portlet renders and only change on reload. + */ + readonly #scheduleLabels: ExperimentScheduleLabels = { + open: this.#dotMessageService.get('experiments.list.schedule.open'), + none: this.#dotMessageService.get('experiments.list.schedule.none') }; - private componentRef: ComponentRef; /** - * Update the list of selected statuses - * @param {Array} $event - * @returns void - * @memberof DotExperimentsListComponent + * Empty-state copy. Resolved once for the same reason as `#scheduleLabels`, and declared + * after the injections because field initialisers run in declaration order. */ - selectedStatusFilter($event: Array): void { - this.dotExperimentsListStore.setFilterStatus($event); - } + /** Any narrowing the user applied, as opposed to a site that simply has no experiments. */ + readonly $hasActiveFilters = computed( + () => + this.store.filter().length > 0 || + this.store.selectedStatuses().length > 0 || + this.store.selectedGoals().length > 0 + ); + + /** The table is replaced by an empty state once a settled load has nothing to show. */ + readonly $isEmpty = computed( + () => !this.$isLoading() && !this.$hasError() && this.$rows().length === 0 + ); /** - * Add new experiment - * @returns void - * @memberof DotExperimentsListComponent + * "Nothing here" and "nothing matched" are different situations and get different copy: the + * first is a site with no experiments, the second is the user's own filters hiding them, and + * only the second is worth offering a way out of. */ - addExperiment(): void { - this.dotExperimentsListStore.openSidebar(); - } + readonly $emptyConfiguration = computed(() => + this.$hasActiveFilters() + ? { + title: this.#dotMessageService.get('experiments.list.no-results.title'), + subtitle: this.#dotMessageService.get('experiments.list.no-results.description'), + icon: 'filter_alt_off', + iconStyle: 'material-symbols-rounded' + } + : { + title: this.#dotMessageService.get('experiments.list.empty.title'), + subtitle: this.#dotMessageService.get('experiments.list.empty.description'), + icon: 'science', + iconStyle: 'material-symbols-rounded' + } + ); /** - * Back to Edit Page / Content - * @returns void - * @memberof DotExperimentsShellComponent + * Shown when the load fails. The error itself is already surfaced by + * `DotHttpErrorManagerService`; this is the screen's own state, so a failed load reads as a + * failure with a way out rather than as an empty list. */ - goToBrowserBack(): void { - this.router.navigate(['edit-page/content'], { - queryParams: { - mode: null, - variantName: null, - experimentId: null - }, - queryParamsHandling: 'merge' + readonly errorConfiguration: PrincipalConfiguration = { + title: this.#dotMessageService.get('experiments.list.error.title'), + subtitle: this.#dotMessageService.get('experiments.error.fetching.data'), + icon: 'error', + iconStyle: 'material-symbols-rounded' + }; + + /** + * Pushes the settled search term into the store. + * + * Guarded against the store's own value: on entry the debounced term and the store agree + * (both seeded from the URL), so nothing is dispatched and a hydrated `?filter=` survives. + * It also stands in for `distinctUntilChanged` — re-typing the same text dispatches nothing. + */ + protected readonly dispatchSearchEffect = effect(() => { + const term = this.#debouncedSearch.value(); + + untracked(() => { + if (term !== this.store.filter()) { + this.#dispatch.filterChanged(term); + } }); + }); + + constructor() { + this.#listenForActionSuccess(); } /** - * Go to the experiment report or configuration depending on the experiment status - * @param {DotExperiment} experiment - Experiment to navigate to - * @returns void - * @memberof DotExperimentsShellComponent + * Options for the two chip filters. Both are built here rather than inside the filter so it + * stays domain-agnostic: it receives translated labels and counts and knows nothing about + * statuses or goals. */ + readonly $statusFilterOptions = computed(() => { + const counts = this.store.statusCounts(); - goToContainerAction(experiment: DotExperiment) { - const route = ['/edit-page/experiments/', experiment.pageId, experiment.id]; + return ExperimentsStatusList.map(({ label, value }) => ({ + value, + label: this.#dotMessageService.get(label), + count: String(counts[value as DotExperimentStatus] ?? 0), + testId: `experiment-status-filter-option-${value.toLowerCase()}` + })); + }); - if ( - experiment.status === DotExperimentStatus.RUNNING || - experiment.status === DotExperimentStatus.ENDED - ) { - route.push('reports'); - } else { - route.push('configuration'); - } + readonly $goalFilterOptions = computed(() => { + const counts = this.store.goalCounts(); - this.router.navigate([...route], { - queryParams: { - mode: null, - variantName: null, - experimentId: null - }, - queryParamsHandling: 'merge' - }); + return [...GOAL_LABEL_KEYS].map(([goal, labelKey]) => ({ + value: goal, + label: this.#dotMessageService.get(labelKey), + count: String(counts[goal] ?? 0), + testId: `experiment-goal-filter-option-${goal.toLowerCase()}` + })); + }); + + /** + * Clears the search box. Writing the signal is enough: the debounced dispatch is driven from + * it, so the store follows on the next tick like any other edit — no separate dispatch here, + * which would race the debounce and apply the empty term twice. + */ + onClearSearch(): void { + this.$searchTerm.set(''); } - private handleSidebar(status: SidebarStatus): void { - if (status && status.isOpen && status.status != ComponentStatus.SAVING) { - this.loadSidebarComponent(); - } else { - this.removeSidebarComponent(); - } + /** Clears every narrowing at once, from the no-results state. */ + onClearFilters(): void { + this.$searchTerm.set(''); + this.#dispatch.statusesChanged([]); + this.#dispatch.goalsChanged([]); } - private loadSidebarComponent(): void { - const sidebarHostRef = this.sidebarHost(); - if (sidebarHostRef) { - sidebarHostRef.viewContainerRef.clear(); - this.componentRef = - sidebarHostRef.viewContainerRef.createComponent( - DotExperimentsCreateComponent - ); - } + onStatusesChange(statuses: string[]): void { + this.#dispatch.statusesChanged(statuses as DotExperimentStatus[]); } - private removeSidebarComponent(): void { - if (this.componentRef) { - const sidebarHostRef = this.sidebarHost(); - if (sidebarHostRef) { - sidebarHostRef.viewContainerRef.clear(); + onGoalsChange(goals: string[]): void { + this.#dispatch.goalsChanged(goals as GOAL_TYPES[]); + } + + /** + * Re-runs the whole flow from the health gate, not just the list. + * + * A failed health check leaves `healthStatus` null, so retrying the list alone would fetch + * experiments the gate never cleared — and `$isLoading` keys off that null, which would pin + * the table to skeletons even after the list came back. Re-checking sets it either way. + */ + onRetry(): void { + this.#dispatch.checkHealth(); + } + + onLazyLoad(event: TableLazyLoadEvent): void { + const rows = (event.rows as number) ?? this.store.perPage(); + const first = (event.first as number) ?? 0; + const page = Math.floor(first / rows) + 1; + + this.#dispatch.pageChanged({ page, perPage: rows }); + + if (event.sortField) { + const field = Array.isArray(event.sortField) ? event.sortField[0] : event.sortField; + const direction: DotExperimentsListSortDirection = + event.sortOrder === -1 ? 'DESC' : 'ASC'; + + // Only when the sort actually moved. PrimeNG's `createLazyLoadMetadata()` puts the + // current sortField and sortOrder on *every* lazy-load event — the initial render and + // each pagination included — and `sortChanged` resets the page. Dispatching it + // unconditionally meant paging to 2 immediately reset to 1, and a `?page=N` deep link + // was undone by the table's own first event. + if (field !== this.store.orderBy() || direction !== this.store.direction()) { + this.#dispatch.sortChanged({ orderBy: field, direction }); } } } + + /** Rebuilds the kebab menu for the given row before the popup opens. */ + onRowMenuToggle(experiment: DotExperiment): void { + this.$rowMenuItems.set(this.#buildRowMenuItems(experiment)); + } + + confirmArchive(experiment: DotExperiment): void { + this.#confirm({ + headerKey: 'experiments.action.archive', + messageKey: 'experiments.action.archive.confirm-question', + acceptLabelKey: 'experiments.action.archive', + accept: () => this.#dispatch.archiveExperiment(experiment) + }); + } + + #buildRowMenuItems(experiment: DotExperiment): MenuItem[] { + const { status } = experiment; + + return [ + { + id: 'experiments-archive', + label: this.#dotMessageService.get('experiments.action.archive'), + visible: isAllowed('archive', status), + command: () => this.confirmArchive(experiment) + }, + { + id: 'experiments-restore', + label: this.#dotMessageService.get('experiments.action.restore'), + visible: status === DotExperimentStatus.ARCHIVED, + // No restore transition exists yet — it lands with #36988. + disabled: true + }, + { + id: 'experiments-cancel-schedule', + label: this.#dotMessageService.get('experiments.configure.scheduling.cancel'), + visible: isAllowed('cancelSchedule', status), + command: () => + this.#confirm({ + headerKey: 'experiments.configure.scheduling.cancel', + messageKey: 'experiments.action.cancel.schedule-confirm', + acceptLabelKey: 'dot.common.dialog.accept', + accept: () => this.#dispatch.cancelScheduleExperiment(experiment) + }) + }, + { + id: 'experiments-end', + label: this.#dotMessageService.get('experiments.action.end-experiment'), + visible: isAllowed('end', status), + command: () => + this.#confirm({ + headerKey: 'experiments.action.end-experiment', + messageKey: 'experiments.action.stop.delete-confirm', + acceptLabelKey: 'experiments.action.end', + accept: () => this.#dispatch.endExperiment(experiment) + }) + }, + { + id: 'experiments-abort', + label: this.#dotMessageService.get('experiments.action.abort.experiment'), + visible: isAllowed('abort', status), + command: () => + this.#confirm({ + headerKey: 'experiments.action.abort.experiment', + messageKey: 'experiments.action.abort.confirm.message', + acceptLabelKey: 'experiments.action.abort.experiment', + accept: () => this.#dispatch.abortExperiment(experiment) + }) + }, + { + id: 'experiments-delete', + label: this.#dotMessageService.get('experiments.action.delete'), + visible: isAllowed('delete', status), + command: () => + this.#confirm({ + headerKey: 'experiments.action.delete', + messageKey: 'experiments.action.delete.confirm-question', + messageArg: experiment.name, + acceptLabelKey: 'experiments.action.delete', + accept: () => this.#dispatch.deleteExperiment(experiment) + }) + }, + { + id: 'experiments-push-publish', + label: this.#dotMessageService.get('contenttypes.content.push_publish'), + visible: isAllowed('pushPublish', status), + command: () => + this.#pushPublishDialogService.open({ + assetIdentifier: experiment.id, + title: this.#dotMessageService.get('contenttypes.content.push_publish') + }) + }, + { + id: 'experiments-add-to-bundle', + label: this.#dotMessageService.get('contenttypes.content.add_to_bundle'), + visible: isAllowed('addToBundle', status), + command: () => this.$addToBundleAssetId.set(experiment.id) + } + ]; + } + + #confirm({ + headerKey, + messageKey, + messageArg, + acceptLabelKey, + accept + }: { + headerKey: string; + messageKey: string; + messageArg?: string; + acceptLabelKey: string; + accept: () => void; + }): void { + this.#confirmationService.confirm({ + key: CONFIGURATION_CONFIRM_DIALOG_KEY, + header: this.#dotMessageService.get(headerKey), + message: this.#dotMessageService.get(messageKey, messageArg ?? ''), + acceptLabel: this.#dotMessageService.get(acceptLabelKey), + rejectLabel: this.#dotMessageService.get('dot.common.dialog.reject'), + rejectButtonStyleClass: 'p-button-secondary', + defaultFocus: 'reject', + closable: true, + closeOnEscape: true, + accept + }); + } + + /** + * The store reloads the list on its own after an action succeeds; the toast is a UI concern + * and therefore lives here. + */ + #listenForActionSuccess(): void { + const successMessages: ReadonlyArray<[EventCreator, string]> = [ + [ + dotExperimentsApiEvents.archiveSucceeded, + 'experiments.action.archive.confirm-message' + ], + [dotExperimentsApiEvents.deleteSucceeded, 'experiments.action.delete.confirm-message'], + [dotExperimentsApiEvents.endSucceeded, 'experiments.action.stop.confirm-message'], + [dotExperimentsApiEvents.abortSucceeded, 'experiments.notification.abort'], + [ + dotExperimentsApiEvents.cancelScheduleSucceeded, + 'experiments.notification.cancel.schedule' + ] + ]; + + successMessages.forEach(([event, messageKey]) => { + this.#events + .on(event) + .pipe(takeUntilDestroyed(this.#destroyRef)) + .subscribe(({ payload }) => + this.#dotMessageDisplayService.push({ + life: SUCCESS_MESSAGE_LIFE, + severity: DotMessageSeverity.SUCCESS, + message: this.#dotMessageService.get(messageKey, payload.name), + type: DotMessageType.SIMPLE_MESSAGE + }) + ); + }); + } } diff --git a/core-web/libs/portlets/dot-experiments/portlet/src/lib/dot-experiments-list/dot-experiments-list.di.spec.ts b/core-web/libs/portlets/dot-experiments/portlet/src/lib/dot-experiments-list/dot-experiments-list.di.spec.ts new file mode 100644 index 000000000000..e7660d9acc90 --- /dev/null +++ b/core-web/libs/portlets/dot-experiments/portlet/src/lib/dot-experiments-list/dot-experiments-list.di.spec.ts @@ -0,0 +1,164 @@ +/** + * Dependency-injection smoke test for the experiments list. + * + * This spec deliberately does **not** mock `DotExperimentsListStore` nor `DotExperimentsService`, + * which is the opposite of `dot-experiments-list.component.spec.ts`. Its whole purpose is to build + * the same injector chain the router builds at runtime and let it fail if a provider is missing. + * + * Why it exists: dotCMS has many `@Injectable()` services with no `providedIn: 'root'`, kept alive + * by the app-level `apps/dotcms-ui/src/app/providers.ts`. A lazily loaded standalone portlet + * inherits none of them automatically, so anything the portlet injects but does not provide throws + * `NG0201: No provider found` on real route activation — a failure neither the store-mocking specs + * nor the AOT build (types only) can see. Two of those shipped in a row. + * + * Only what the surrounding application would supply is mocked here: the app-level services from + * `providers.ts` and the root-provided `GlobalStore`. Everything the portlet itself is responsible + * for is left real. Replacing the store or the service with a mock "to simplify" destroys the whole + * value of this file. + */ +import { byTestId, createComponentFactory, mockProvider, Spectator } from '@openng/spectator/jest'; + +import { provideHttpClient } from '@angular/common/http'; +import { HttpTestingController, provideHttpClientTesting } from '@angular/common/http/testing'; +import { provideLocationMocks } from '@angular/common/testing'; +import { signal } from '@angular/core'; +import { ActivatedRoute, provideRouter } from '@angular/router'; + +import { + DotExperimentsService, + DotHttpErrorManagerService, + DotMessageDisplayService, + DotMessageService +} from '@dotcms/data-access'; +import { DotPushPublishDialogService, LoggerService } from '@dotcms/dotcms-js'; +import { HealthStatusTypes } from '@dotcms/dotcms-models'; +import { GlobalStore } from '@dotcms/store'; +import { getExperimentMock, MockDotMessageService } from '@dotcms/utils-testing'; + +import { DotExperimentsListComponent } from './dot-experiments-list.component'; + +import { DotExperimentsListStore } from '../store/dot-experiments-list.store'; + +const CURRENT_SITE_ID = 'site-123'; + +/** `GlobalStore` is `providedIn: 'root'`; only the signals this screen reads are stubbed. */ +const globalStoreMock = { + currentSiteId: signal(CURRENT_SITE_ID), + siteDetails: signal({ + identifier: CURRENT_SITE_ID, + hostname: 'demo.dotcms.com', + aliases: null, + archived: false + }) +}; + +/** A pristine `/experiments` URL: the store hydrates its view state from these params. */ +const activatedRouteStub = { snapshot: { queryParams: {} } }; + +/** Providers declared by the `@Component` decorator, read from the JIT metadata. */ +const declaredProviders = (): unknown[] => { + const annotations = ( + DotExperimentsListComponent as unknown as { __annotations__?: { providers?: unknown[] }[] } + ).__annotations__; + + return annotations?.[0]?.providers ?? []; +}; + +describe('DotExperimentsListComponent dependency injection', () => { + let spectator: Spectator; + let httpTesting: HttpTestingController; + + const createComponent = createComponentFactory({ + component: DotExperimentsListComponent, + // Nothing here overrides the component's own `providers`: the store, `ConfirmationService` + // and `DotExperimentsService` must all resolve exactly as they do in the browser. + providers: [ + provideHttpClient(), + provideHttpClientTesting(), + provideRouter([]), + provideLocationMocks(), + { provide: ActivatedRoute, useValue: activatedRouteStub }, + { provide: GlobalStore, useValue: globalStoreMock }, + // Everything below stands in for the app-level `providers.ts`, which is outside + // this lib and therefore not the portlet's responsibility. + { provide: DotMessageService, useValue: new MockDotMessageService({}) }, + mockProvider(DotMessageDisplayService), + mockProvider(DotHttpErrorManagerService), + mockProvider(DotPushPublishDialogService), + mockProvider(LoggerService) + ], + detectChanges: false + }); + + const PAGE_ID = 'page-di-1'; + + /** + * The real store holds an empty shell until the Analytics gate answers, so the list is only + * rendered after the health check — and the real service is what issues that request. + * + * A row is flushed rather than an empty list, deliberately: with nothing to show the table is + * replaced by the empty state, and none of the per-row children — the kebab, the tags, the + * action buttons — would be constructed. Those children are exactly what this spec is here to + * instantiate against a real injector. + */ + const openList = () => { + spectator.detectChanges(); + + httpTesting + .expectOne((request) => request.url.endsWith('/experiments/health')) + .flush({ entity: { health: HealthStatusTypes.OK } }); + httpTesting + .expectOne((request) => request.url.endsWith('/api/v1/experiments')) + .flush({ + entity: [{ ...getExperimentMock(0), id: 'exp-di-1', pageId: PAGE_ID }] + }); + // The page lookup is what resolves the row's site; without it the site filter drops the + // experiment and we are back to an empty table. + httpTesting + .expectOne((request) => request.url.endsWith('/api/content/_search')) + .flush({ + entity: { + jsonObjectView: { + contentlets: [ + { identifier: PAGE_ID, url: '/di-page', host: CURRENT_SITE_ID } + ] + } + } + }); + + spectator.detectChanges(); + }; + + it('should construct with the real store and the real DotExperimentsService', () => { + expect(() => (spectator = createComponent())).not.toThrow(); + expect(spectator.component.store).toBeTruthy(); + }); + + it('should render the list without a missing provider in any child of the template', () => { + spectator = createComponent(); + httpTesting = spectator.inject(HttpTestingController); + + expect(() => openList()).not.toThrow(); + // Guards the test against silently asserting on an empty shell. + expect(spectator.query(byTestId('experiments-table'))).not.toBeNull(); + }); + + it('should render the add to bundle dialog without a missing provider', () => { + spectator = createComponent(); + httpTesting = spectator.inject(HttpTestingController); + openList(); + + spectator.component.$addToBundleAssetId.set('experiment-1'); + + expect(() => spectator.detectChanges()).not.toThrow(); + expect(spectator.query('dot-add-to-bundle')).not.toBeNull(); + }); + + it('should declare the providers this portlet owns', () => { + const providers = declaredProviders(); + + expect(providers.length).toBeGreaterThan(0); + expect(providers).toContain(DotExperimentsService); + expect(providers).toContain(DotExperimentsListStore); + }); +}); diff --git a/core-web/libs/portlets/dot-experiments/portlet/src/lib/lib.routes.spec.ts b/core-web/libs/portlets/dot-experiments/portlet/src/lib/lib.routes.spec.ts new file mode 100644 index 000000000000..c9cca1332226 --- /dev/null +++ b/core-web/libs/portlets/dot-experiments/portlet/src/lib/lib.routes.spec.ts @@ -0,0 +1,41 @@ +import { Route } from '@angular/router'; + +import { DotPushPublishEnvironmentsResolver } from '@dotcms/ui'; + +import { dotExperimentsPortletRoutes } from './lib.routes'; + +describe('dotExperimentsPortletRoutes', () => { + const listRoute = dotExperimentsPortletRoutes.find((route) => route.path === '') as Route; + + it('should expose the list route', () => { + expect(listRoute).toBeDefined(); + expect(listRoute.loadComponent).toBeDefined(); + }); + + it('should not wire the screens owned by follow-up issues', () => { + // `new`, `:id/configuration` and `:id/results` land with #36990+. Until then an + // unimplemented deep link must fall through rather than resolve to a blank screen. + const paths = dotExperimentsPortletRoutes.map((route) => route.path); + + expect(paths).toEqual(['']); + }); + + describe('resolvers', () => { + it('should resolve the push publish environments', () => { + expect(listRoute.resolve?.['pushPublishEnvironments']).toBe( + DotPushPublishEnvironmentsResolver + ); + }); + + it('should provide every class resolver it references', () => { + // `DotPushPublishEnvironmentsResolver` is `@Injectable()` with no `providedIn`, so + // a `resolve` entry without a matching `providers` entry compiles and builds fine + // and then throws NG0201 the moment a user opens the portlet. + const provided = new Set(listRoute.providers ?? []); + const referenced = Object.values(listRoute.resolve ?? {}); + + expect(referenced.length).toBeGreaterThan(0); + referenced.forEach((resolver) => expect(provided.has(resolver)).toBe(true)); + }); + }); +}); diff --git a/core-web/libs/portlets/dot-experiments/portlet/src/lib/lib.routes.ts b/core-web/libs/portlets/dot-experiments/portlet/src/lib/lib.routes.ts index 938ef5386a6d..0a8fe3a19606 100644 --- a/core-web/libs/portlets/dot-experiments/portlet/src/lib/lib.routes.ts +++ b/core-web/libs/portlets/dot-experiments/portlet/src/lib/lib.routes.ts @@ -1,60 +1,31 @@ import { Routes } from '@angular/router'; -import { ExperimentsConfigProperties } from '@dotcms/dotcms-models'; -import { DotExperimentsConfigResolver } from '@dotcms/portlets/dot-experiments/data-access'; -import { DotEnterpriseLicenseResolver, DotPushPublishEnvironmentsResolver } from '@dotcms/ui'; +import { DotPushPublishEnvironmentsResolver } from '@dotcms/ui'; -import { DotExperimentsAnalyticAppMisconfigurationComponent } from './dot-experiments-analytic-app-misconfiguration/dot-experiments-analytic-app-misconfiguration.component'; -import { DotExperimentsConfigurationComponent } from './dot-experiments-configuration/dot-experiments-configuration.component'; -import { DotExperimentsListComponent } from './dot-experiments-list/dot-experiments-list.component'; -import { DotExperimentsReportsComponent } from './dot-experiments-reports/dot-experiments-reports.component'; -import { DotExperimentsShellComponent } from './dot-experiments-shell/dot-experiments-shell.component'; -import { AnalyticsAppGuard } from './shared/guards/dot-experiments-analytic-app.guard'; - -export const dotExperimentsRoutes: Routes = [ - { - path: 'analytic-app-misconfiguration', - component: DotExperimentsAnalyticAppMisconfigurationComponent, - title: 'experiments.container.no-analytic-app-configured.title' - }, +/** + * Routes for the Experiments portlet (registered under `/experiments`). + * + * This is the portlet that replaces the per-page UVE experiments screens, which live on + * under `./old/` and keep serving `dotExperimentsRoutes` unchanged until they are retired. + * + * Only the list route is wired today. The `new`, `:id/configuration` and `:id/results` + * screens are delivered by follow-up issues (#36990+) and are intentionally absent so + * the router surfaces an honest 404 instead of falling back to the legacy UVE screens. + */ +export const dotExperimentsPortletRoutes: Routes = [ { - path: ':pageId', - component: DotExperimentsShellComponent, + path: '', + title: 'experiment.container.list.title', + // `DotPushPublishEnvironmentsResolver` is `@Injectable()` without `providedIn: 'root'`, + // so referencing it in `resolve` is not enough — it has to be provided on the route or + // the router throws NG0201 on activation. + providers: [DotPushPublishEnvironmentsResolver], resolve: { - isEnterprise: DotEnterpriseLicenseResolver, pushPublishEnvironments: DotPushPublishEnvironmentsResolver }, - canActivateChild: [AnalyticsAppGuard], - children: [ - { - path: '', - title: 'experiment.container.list.title', - component: DotExperimentsListComponent - }, - { - path: ':experimentId/configuration', - title: 'experiment.container.configuration.title', - resolve: { - config: DotExperimentsConfigResolver - }, - data: { - experimentsConfigProps: [ - ExperimentsConfigProperties.EXPERIMENTS_MIN_DURATION, - ExperimentsConfigProperties.EXPERIMENTS_MAX_DURATION - ] - }, - component: DotExperimentsConfigurationComponent - }, - { - path: ':experimentId/reports', - title: 'experiment.container.report.title', - component: DotExperimentsReportsComponent - } - ] - }, - { - path: '**', - redirectTo: 'analytic-app-misconfiguration', - pathMatch: 'full' + loadComponent: () => + import('./dot-experiments-list/dot-experiments-list.component').then( + (m) => m.DotExperimentsListComponent + ) } ]; diff --git a/core-web/libs/portlets/dot-experiments/portlet/src/lib/old/dot-experiments-analytic-app-misconfiguration/dot-experiments-analytic-app-misconfiguration.component.html b/core-web/libs/portlets/dot-experiments/portlet/src/lib/old/dot-experiments-analytic-app-misconfiguration/dot-experiments-analytic-app-misconfiguration.component.html new file mode 100644 index 000000000000..201e16d22df9 --- /dev/null +++ b/core-web/libs/portlets/dot-experiments/portlet/src/lib/old/dot-experiments-analytic-app-misconfiguration/dot-experiments-analytic-app-misconfiguration.component.html @@ -0,0 +1,6 @@ + + +
+ +
diff --git a/core-web/libs/portlets/dot-experiments/portlet/src/lib/dot-experiments-analytic-app-misconfiguration/dot-experiments-analytic-app-misconfiguration.component.spec.ts b/core-web/libs/portlets/dot-experiments/portlet/src/lib/old/dot-experiments-analytic-app-misconfiguration/dot-experiments-analytic-app-misconfiguration.component.spec.ts similarity index 100% rename from core-web/libs/portlets/dot-experiments/portlet/src/lib/dot-experiments-analytic-app-misconfiguration/dot-experiments-analytic-app-misconfiguration.component.spec.ts rename to core-web/libs/portlets/dot-experiments/portlet/src/lib/old/dot-experiments-analytic-app-misconfiguration/dot-experiments-analytic-app-misconfiguration.component.spec.ts diff --git a/core-web/libs/portlets/dot-experiments/portlet/src/lib/dot-experiments-analytic-app-misconfiguration/dot-experiments-analytic-app-misconfiguration.component.ts b/core-web/libs/portlets/dot-experiments/portlet/src/lib/old/dot-experiments-analytic-app-misconfiguration/dot-experiments-analytic-app-misconfiguration.component.ts similarity index 100% rename from core-web/libs/portlets/dot-experiments/portlet/src/lib/dot-experiments-analytic-app-misconfiguration/dot-experiments-analytic-app-misconfiguration.component.ts rename to core-web/libs/portlets/dot-experiments/portlet/src/lib/old/dot-experiments-analytic-app-misconfiguration/dot-experiments-analytic-app-misconfiguration.component.ts diff --git a/core-web/libs/portlets/dot-experiments/portlet/src/lib/dot-experiments-configuration/components/dot-experiments-configuration-goal-select/dot-experiments-configuration-goal-select.component.html b/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 similarity index 97% rename from core-web/libs/portlets/dot-experiments/portlet/src/lib/dot-experiments-configuration/components/dot-experiments-configuration-goal-select/dot-experiments-configuration-goal-select.component.html rename to 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 index a6946cea0a6f..317bef6e04f1 100644 --- a/core-web/libs/portlets/dot-experiments/portlet/src/lib/dot-experiments-configuration/components/dot-experiments-configuration-goal-select/dot-experiments-configuration-goal-select.component.html +++ b/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 @@ -52,10 +52,10 @@
-
+
-

+

check_circle {{ 'experiments.configure.goals.name' | dm }}

-

+

{{ 'experiments.configure.goals.no.seleted.goal.message' | dm }}

@@ -30,7 +30,7 @@

[isEmpty]="false">
{{ vm.goals.primary.type === GOAL_TYPES.URL_PARAMETER @@ -38,15 +38,15 @@

: ('experiments.goal.conditions.parameter' | dm) }}

-
+
{{ 'experiments.goal.conditions.operator' | dm }}
-
+
{{ 'experiments.goal.conditions.value' | dm }}
-
+
@if (row.isDefault) { {{ 'experiments.goal.conditions.default' | dm }} @@ -59,11 +59,11 @@

: row.parameter }}

-
+
{{ row.operator | lowercase }}
{{ vm.goals.primary.type === GOAL_TYPES.URL_PARAMETER @@ -74,8 +74,8 @@

} @else { -
-

+
+

{{ GOALS_METADATA_MAP[vm.goals.primary.type].label | dm }} @@ -84,7 +84,7 @@

{{ vm.goals.primary.name | dm }}

-
+
-
+
@@ -32,7 +32,7 @@ data-testId="scheduling-startDate" formControlName="startDate" />
-
+
diff --git a/core-web/libs/portlets/dot-experiments/portlet/src/lib/dot-experiments-configuration/components/dot-experiments-configuration-scheduling-add/dot-experiments-configuration-scheduling-add.component.spec.ts b/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.spec.ts similarity index 100% rename from core-web/libs/portlets/dot-experiments/portlet/src/lib/dot-experiments-configuration/components/dot-experiments-configuration-scheduling-add/dot-experiments-configuration-scheduling-add.component.spec.ts rename to 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.spec.ts diff --git a/core-web/libs/portlets/dot-experiments/portlet/src/lib/dot-experiments-configuration/components/dot-experiments-configuration-scheduling-add/dot-experiments-configuration-scheduling-add.component.ts b/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.ts similarity index 100% rename from core-web/libs/portlets/dot-experiments/portlet/src/lib/dot-experiments-configuration/components/dot-experiments-configuration-scheduling-add/dot-experiments-configuration-scheduling-add.component.ts rename to 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.ts diff --git a/core-web/libs/portlets/dot-experiments/portlet/src/lib/dot-experiments-configuration/components/dot-experiments-configuration-scheduling/dot-experiments-configuration-scheduling.component.html b/core-web/libs/portlets/dot-experiments/portlet/src/lib/old/dot-experiments-configuration/components/dot-experiments-configuration-scheduling/dot-experiments-configuration-scheduling.component.html similarity index 97% rename from core-web/libs/portlets/dot-experiments/portlet/src/lib/dot-experiments-configuration/components/dot-experiments-configuration-scheduling/dot-experiments-configuration-scheduling.component.html rename to core-web/libs/portlets/dot-experiments/portlet/src/lib/old/dot-experiments-configuration/components/dot-experiments-configuration-scheduling/dot-experiments-configuration-scheduling.component.html index 68bf9f6c4338..94658d09092d 100644 --- a/core-web/libs/portlets/dot-experiments/portlet/src/lib/dot-experiments-configuration/components/dot-experiments-configuration-scheduling/dot-experiments-configuration-scheduling.component.html +++ b/core-web/libs/portlets/dot-experiments/portlet/src/lib/old/dot-experiments-configuration/components/dot-experiments-configuration-scheduling/dot-experiments-configuration-scheduling.component.html @@ -2,7 +2,7 @@

-
+
{{ 'experiments.configure.traffic.allocation' | dm }}

@@ -21,7 +21,7 @@

-
+
maxlength="3" pInputText type="number" /> - +
diff --git a/core-web/libs/portlets/dot-experiments/portlet/src/lib/dot-experiments-configuration/components/dot-experiments-configuration-traffic-allocation-add/dot-experiments-configuration-traffic-allocation-add.component.spec.ts b/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.spec.ts similarity index 100% rename from core-web/libs/portlets/dot-experiments/portlet/src/lib/dot-experiments-configuration/components/dot-experiments-configuration-traffic-allocation-add/dot-experiments-configuration-traffic-allocation-add.component.spec.ts rename to 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.spec.ts diff --git a/core-web/libs/portlets/dot-experiments/portlet/src/lib/dot-experiments-configuration/components/dot-experiments-configuration-traffic-allocation-add/dot-experiments-configuration-traffic-allocation-add.component.ts b/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.ts similarity index 100% rename from core-web/libs/portlets/dot-experiments/portlet/src/lib/dot-experiments-configuration/components/dot-experiments-configuration-traffic-allocation-add/dot-experiments-configuration-traffic-allocation-add.component.ts rename to 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.ts diff --git a/core-web/libs/portlets/dot-experiments/portlet/src/lib/dot-experiments-configuration/components/dot-experiments-configuration-traffic-split-add/dot-experiments-configuration-traffic-split-add.component.html b/core-web/libs/portlets/dot-experiments/portlet/src/lib/old/dot-experiments-configuration/components/dot-experiments-configuration-traffic-split-add/dot-experiments-configuration-traffic-split-add.component.html similarity index 100% rename from core-web/libs/portlets/dot-experiments/portlet/src/lib/dot-experiments-configuration/components/dot-experiments-configuration-traffic-split-add/dot-experiments-configuration-traffic-split-add.component.html rename to core-web/libs/portlets/dot-experiments/portlet/src/lib/old/dot-experiments-configuration/components/dot-experiments-configuration-traffic-split-add/dot-experiments-configuration-traffic-split-add.component.html diff --git a/core-web/libs/portlets/dot-experiments/portlet/src/lib/dot-experiments-configuration/components/dot-experiments-configuration-traffic-split-add/dot-experiments-configuration-traffic-split-add.component.spec.ts b/core-web/libs/portlets/dot-experiments/portlet/src/lib/old/dot-experiments-configuration/components/dot-experiments-configuration-traffic-split-add/dot-experiments-configuration-traffic-split-add.component.spec.ts similarity index 100% rename from core-web/libs/portlets/dot-experiments/portlet/src/lib/dot-experiments-configuration/components/dot-experiments-configuration-traffic-split-add/dot-experiments-configuration-traffic-split-add.component.spec.ts rename to core-web/libs/portlets/dot-experiments/portlet/src/lib/old/dot-experiments-configuration/components/dot-experiments-configuration-traffic-split-add/dot-experiments-configuration-traffic-split-add.component.spec.ts diff --git a/core-web/libs/portlets/dot-experiments/portlet/src/lib/dot-experiments-configuration/components/dot-experiments-configuration-traffic-split-add/dot-experiments-configuration-traffic-split-add.component.ts b/core-web/libs/portlets/dot-experiments/portlet/src/lib/old/dot-experiments-configuration/components/dot-experiments-configuration-traffic-split-add/dot-experiments-configuration-traffic-split-add.component.ts similarity index 100% rename from core-web/libs/portlets/dot-experiments/portlet/src/lib/dot-experiments-configuration/components/dot-experiments-configuration-traffic-split-add/dot-experiments-configuration-traffic-split-add.component.ts rename to core-web/libs/portlets/dot-experiments/portlet/src/lib/old/dot-experiments-configuration/components/dot-experiments-configuration-traffic-split-add/dot-experiments-configuration-traffic-split-add.component.ts diff --git a/core-web/libs/portlets/dot-experiments/portlet/src/lib/dot-experiments-configuration/components/dot-experiments-configuration-traffic/dot-experiments-configuration-traffic.component.html b/core-web/libs/portlets/dot-experiments/portlet/src/lib/old/dot-experiments-configuration/components/dot-experiments-configuration-traffic/dot-experiments-configuration-traffic.component.html similarity index 95% rename from core-web/libs/portlets/dot-experiments/portlet/src/lib/dot-experiments-configuration/components/dot-experiments-configuration-traffic/dot-experiments-configuration-traffic.component.html rename to core-web/libs/portlets/dot-experiments/portlet/src/lib/old/dot-experiments-configuration/components/dot-experiments-configuration-traffic/dot-experiments-configuration-traffic.component.html index 6be202f5c2d5..905c5ffa0cd5 100644 --- a/core-web/libs/portlets/dot-experiments/portlet/src/lib/dot-experiments-configuration/components/dot-experiments-configuration-traffic/dot-experiments-configuration-traffic.component.html +++ b/core-web/libs/portlets/dot-experiments/portlet/src/lib/old/dot-experiments-configuration/components/dot-experiments-configuration-traffic/dot-experiments-configuration-traffic.component.html @@ -2,7 +2,7 @@

diff --git a/core-web/libs/portlets/dot-experiments/portlet/src/lib/dot-experiments-configuration/components/dot-experiments-configuration-variants-add/dot-experiments-configuration-variants-add.component.spec.ts b/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.spec.ts similarity index 100% rename from core-web/libs/portlets/dot-experiments/portlet/src/lib/dot-experiments-configuration/components/dot-experiments-configuration-variants-add/dot-experiments-configuration-variants-add.component.spec.ts rename to 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.spec.ts diff --git a/core-web/libs/portlets/dot-experiments/portlet/src/lib/dot-experiments-configuration/components/dot-experiments-configuration-variants-add/dot-experiments-configuration-variants-add.component.ts b/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 similarity index 100% rename from core-web/libs/portlets/dot-experiments/portlet/src/lib/dot-experiments-configuration/components/dot-experiments-configuration-variants-add/dot-experiments-configuration-variants-add.component.ts rename to 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 diff --git a/core-web/libs/portlets/dot-experiments/portlet/src/lib/dot-experiments-configuration/components/dot-experiments-configuration-variants/dot-experiments-configuration-variants.component.html b/core-web/libs/portlets/dot-experiments/portlet/src/lib/old/dot-experiments-configuration/components/dot-experiments-configuration-variants/dot-experiments-configuration-variants.component.html similarity index 100% rename from core-web/libs/portlets/dot-experiments/portlet/src/lib/dot-experiments-configuration/components/dot-experiments-configuration-variants/dot-experiments-configuration-variants.component.html rename to core-web/libs/portlets/dot-experiments/portlet/src/lib/old/dot-experiments-configuration/components/dot-experiments-configuration-variants/dot-experiments-configuration-variants.component.html diff --git a/core-web/libs/portlets/dot-experiments/portlet/src/lib/dot-experiments-configuration/components/dot-experiments-configuration-variants/dot-experiments-configuration-variants.component.spec.ts b/core-web/libs/portlets/dot-experiments/portlet/src/lib/old/dot-experiments-configuration/components/dot-experiments-configuration-variants/dot-experiments-configuration-variants.component.spec.ts similarity index 100% rename from core-web/libs/portlets/dot-experiments/portlet/src/lib/dot-experiments-configuration/components/dot-experiments-configuration-variants/dot-experiments-configuration-variants.component.spec.ts rename to core-web/libs/portlets/dot-experiments/portlet/src/lib/old/dot-experiments-configuration/components/dot-experiments-configuration-variants/dot-experiments-configuration-variants.component.spec.ts diff --git a/core-web/libs/portlets/dot-experiments/portlet/src/lib/dot-experiments-configuration/components/dot-experiments-configuration-variants/dot-experiments-configuration-variants.component.ts b/core-web/libs/portlets/dot-experiments/portlet/src/lib/old/dot-experiments-configuration/components/dot-experiments-configuration-variants/dot-experiments-configuration-variants.component.ts similarity index 100% rename from core-web/libs/portlets/dot-experiments/portlet/src/lib/dot-experiments-configuration/components/dot-experiments-configuration-variants/dot-experiments-configuration-variants.component.ts rename to core-web/libs/portlets/dot-experiments/portlet/src/lib/old/dot-experiments-configuration/components/dot-experiments-configuration-variants/dot-experiments-configuration-variants.component.ts diff --git a/core-web/libs/portlets/dot-experiments/portlet/src/lib/dot-experiments-configuration/dot-experiments-configuration.component.html b/core-web/libs/portlets/dot-experiments/portlet/src/lib/old/dot-experiments-configuration/dot-experiments-configuration.component.html similarity index 100% rename from core-web/libs/portlets/dot-experiments/portlet/src/lib/dot-experiments-configuration/dot-experiments-configuration.component.html rename to core-web/libs/portlets/dot-experiments/portlet/src/lib/old/dot-experiments-configuration/dot-experiments-configuration.component.html diff --git a/core-web/libs/portlets/dot-experiments/portlet/src/lib/dot-experiments-configuration/dot-experiments-configuration.component.spec.ts b/core-web/libs/portlets/dot-experiments/portlet/src/lib/old/dot-experiments-configuration/dot-experiments-configuration.component.spec.ts similarity index 100% rename from core-web/libs/portlets/dot-experiments/portlet/src/lib/dot-experiments-configuration/dot-experiments-configuration.component.spec.ts rename to core-web/libs/portlets/dot-experiments/portlet/src/lib/old/dot-experiments-configuration/dot-experiments-configuration.component.spec.ts diff --git a/core-web/libs/portlets/dot-experiments/portlet/src/lib/dot-experiments-configuration/dot-experiments-configuration.component.ts b/core-web/libs/portlets/dot-experiments/portlet/src/lib/old/dot-experiments-configuration/dot-experiments-configuration.component.ts similarity index 100% rename from core-web/libs/portlets/dot-experiments/portlet/src/lib/dot-experiments-configuration/dot-experiments-configuration.component.ts rename to core-web/libs/portlets/dot-experiments/portlet/src/lib/old/dot-experiments-configuration/dot-experiments-configuration.component.ts diff --git a/core-web/libs/portlets/dot-experiments/portlet/src/lib/dot-experiments-configuration/store/dot-experiments-configuration-store.spec.ts b/core-web/libs/portlets/dot-experiments/portlet/src/lib/old/dot-experiments-configuration/store/dot-experiments-configuration-store.spec.ts similarity index 100% rename from core-web/libs/portlets/dot-experiments/portlet/src/lib/dot-experiments-configuration/store/dot-experiments-configuration-store.spec.ts rename to core-web/libs/portlets/dot-experiments/portlet/src/lib/old/dot-experiments-configuration/store/dot-experiments-configuration-store.spec.ts diff --git a/core-web/libs/portlets/dot-experiments/portlet/src/lib/dot-experiments-configuration/store/dot-experiments-configuration-store.ts b/core-web/libs/portlets/dot-experiments/portlet/src/lib/old/dot-experiments-configuration/store/dot-experiments-configuration-store.ts similarity index 100% rename from core-web/libs/portlets/dot-experiments/portlet/src/lib/dot-experiments-configuration/store/dot-experiments-configuration-store.ts rename to core-web/libs/portlets/dot-experiments/portlet/src/lib/old/dot-experiments-configuration/store/dot-experiments-configuration-store.ts diff --git a/core-web/libs/portlets/dot-experiments/portlet/src/lib/dot-experiments-list/components/dot-experiments-create/dot-experiments-create.component.html b/core-web/libs/portlets/dot-experiments/portlet/src/lib/old/dot-experiments-list/components/dot-experiments-create/dot-experiments-create.component.html similarity index 100% rename from core-web/libs/portlets/dot-experiments/portlet/src/lib/dot-experiments-list/components/dot-experiments-create/dot-experiments-create.component.html rename to core-web/libs/portlets/dot-experiments/portlet/src/lib/old/dot-experiments-list/components/dot-experiments-create/dot-experiments-create.component.html diff --git a/core-web/libs/portlets/dot-experiments/portlet/src/lib/dot-experiments-list/components/dot-experiments-create/dot-experiments-create.component.spec.ts b/core-web/libs/portlets/dot-experiments/portlet/src/lib/old/dot-experiments-list/components/dot-experiments-create/dot-experiments-create.component.spec.ts similarity index 100% rename from core-web/libs/portlets/dot-experiments/portlet/src/lib/dot-experiments-list/components/dot-experiments-create/dot-experiments-create.component.spec.ts rename to core-web/libs/portlets/dot-experiments/portlet/src/lib/old/dot-experiments-list/components/dot-experiments-create/dot-experiments-create.component.spec.ts diff --git a/core-web/libs/portlets/dot-experiments/portlet/src/lib/dot-experiments-list/components/dot-experiments-create/dot-experiments-create.component.ts b/core-web/libs/portlets/dot-experiments/portlet/src/lib/old/dot-experiments-list/components/dot-experiments-create/dot-experiments-create.component.ts similarity index 100% rename from core-web/libs/portlets/dot-experiments/portlet/src/lib/dot-experiments-list/components/dot-experiments-create/dot-experiments-create.component.ts rename to core-web/libs/portlets/dot-experiments/portlet/src/lib/old/dot-experiments-list/components/dot-experiments-create/dot-experiments-create.component.ts diff --git a/core-web/libs/portlets/dot-experiments/portlet/src/lib/dot-experiments-list/components/dot-experiments-list-skeleton/dot-experiments-list-skeleton.component.html b/core-web/libs/portlets/dot-experiments/portlet/src/lib/old/dot-experiments-list/components/dot-experiments-list-skeleton/dot-experiments-list-skeleton.component.html similarity index 93% rename from core-web/libs/portlets/dot-experiments/portlet/src/lib/dot-experiments-list/components/dot-experiments-list-skeleton/dot-experiments-list-skeleton.component.html rename to core-web/libs/portlets/dot-experiments/portlet/src/lib/old/dot-experiments-list/components/dot-experiments-list-skeleton/dot-experiments-list-skeleton.component.html index 98cc4587e81d..50d813370020 100644 --- a/core-web/libs/portlets/dot-experiments/portlet/src/lib/dot-experiments-list/components/dot-experiments-list-skeleton/dot-experiments-list-skeleton.component.html +++ b/core-web/libs/portlets/dot-experiments/portlet/src/lib/old/dot-experiments-list/components/dot-experiments-list-skeleton/dot-experiments-list-skeleton.component.html @@ -1,6 +1,6 @@ -
+
-
+
diff --git a/core-web/libs/portlets/dot-experiments/portlet/src/lib/dot-experiments-list/components/dot-experiments-list-skeleton/dot-experiments-list-skeleton.component.ts b/core-web/libs/portlets/dot-experiments/portlet/src/lib/old/dot-experiments-list/components/dot-experiments-list-skeleton/dot-experiments-list-skeleton.component.ts similarity index 100% rename from core-web/libs/portlets/dot-experiments/portlet/src/lib/dot-experiments-list/components/dot-experiments-list-skeleton/dot-experiments-list-skeleton.component.ts rename to core-web/libs/portlets/dot-experiments/portlet/src/lib/old/dot-experiments-list/components/dot-experiments-list-skeleton/dot-experiments-list-skeleton.component.ts diff --git a/core-web/libs/portlets/dot-experiments/portlet/src/lib/dot-experiments-list/components/dot-experiments-list-table/dot-experiments-list-table.component.html b/core-web/libs/portlets/dot-experiments/portlet/src/lib/old/dot-experiments-list/components/dot-experiments-list-table/dot-experiments-list-table.component.html similarity index 97% rename from core-web/libs/portlets/dot-experiments/portlet/src/lib/dot-experiments-list/components/dot-experiments-list-table/dot-experiments-list-table.component.html rename to core-web/libs/portlets/dot-experiments/portlet/src/lib/old/dot-experiments-list/components/dot-experiments-list-table/dot-experiments-list-table.component.html index dcdd87ae2d92..de3ddcc11ac0 100644 --- a/core-web/libs/portlets/dot-experiments/portlet/src/lib/dot-experiments-list/components/dot-experiments-list-table/dot-experiments-list-table.component.html +++ b/core-web/libs/portlets/dot-experiments/portlet/src/lib/old/dot-experiments-list/components/dot-experiments-list-table/dot-experiments-list-table.component.html @@ -1,4 +1,4 @@ -
+
@if ($experimentGroupedByStatus()?.length) { @for (group of $experimentGroupedByStatus() ?? []; track group.status) { diff --git a/core-web/libs/portlets/dot-experiments/portlet/src/lib/dot-experiments-list/components/dot-experiments-list-table/dot-experiments-list-table.component.spec.ts b/core-web/libs/portlets/dot-experiments/portlet/src/lib/old/dot-experiments-list/components/dot-experiments-list-table/dot-experiments-list-table.component.spec.ts similarity index 100% rename from core-web/libs/portlets/dot-experiments/portlet/src/lib/dot-experiments-list/components/dot-experiments-list-table/dot-experiments-list-table.component.spec.ts rename to core-web/libs/portlets/dot-experiments/portlet/src/lib/old/dot-experiments-list/components/dot-experiments-list-table/dot-experiments-list-table.component.spec.ts diff --git a/core-web/libs/portlets/dot-experiments/portlet/src/lib/dot-experiments-list/components/dot-experiments-list-table/dot-experiments-list-table.component.ts b/core-web/libs/portlets/dot-experiments/portlet/src/lib/old/dot-experiments-list/components/dot-experiments-list-table/dot-experiments-list-table.component.ts similarity index 100% rename from core-web/libs/portlets/dot-experiments/portlet/src/lib/dot-experiments-list/components/dot-experiments-list-table/dot-experiments-list-table.component.ts rename to core-web/libs/portlets/dot-experiments/portlet/src/lib/old/dot-experiments-list/components/dot-experiments-list-table/dot-experiments-list-table.component.ts diff --git a/core-web/libs/portlets/dot-experiments/portlet/src/lib/dot-experiments-list/components/dot-experiments-status-filter/dot-experiments-status-filter.component.html b/core-web/libs/portlets/dot-experiments/portlet/src/lib/old/dot-experiments-list/components/dot-experiments-status-filter/dot-experiments-status-filter.component.html similarity index 100% rename from core-web/libs/portlets/dot-experiments/portlet/src/lib/dot-experiments-list/components/dot-experiments-status-filter/dot-experiments-status-filter.component.html rename to core-web/libs/portlets/dot-experiments/portlet/src/lib/old/dot-experiments-list/components/dot-experiments-status-filter/dot-experiments-status-filter.component.html diff --git a/core-web/libs/portlets/dot-experiments/portlet/src/lib/dot-experiments-list/components/dot-experiments-status-filter/dot-experiments-status-filter.component.spec.ts b/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 similarity index 100% rename from core-web/libs/portlets/dot-experiments/portlet/src/lib/dot-experiments-list/components/dot-experiments-status-filter/dot-experiments-status-filter.component.spec.ts rename to 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 diff --git a/core-web/libs/portlets/dot-experiments/portlet/src/lib/dot-experiments-list/components/dot-experiments-status-filter/dot-experiments-status-filter.component.ts b/core-web/libs/portlets/dot-experiments/portlet/src/lib/old/dot-experiments-list/components/dot-experiments-status-filter/dot-experiments-status-filter.component.ts similarity index 100% rename from core-web/libs/portlets/dot-experiments/portlet/src/lib/dot-experiments-list/components/dot-experiments-status-filter/dot-experiments-status-filter.component.ts rename to core-web/libs/portlets/dot-experiments/portlet/src/lib/old/dot-experiments-list/components/dot-experiments-status-filter/dot-experiments-status-filter.component.ts diff --git a/core-web/libs/portlets/dot-experiments/portlet/src/lib/old/dot-experiments-list/dot-experiments-list.component.html b/core-web/libs/portlets/dot-experiments/portlet/src/lib/old/dot-experiments-list/dot-experiments-list.component.html new file mode 100644 index 000000000000..9a4fea8dfc21 --- /dev/null +++ b/core-web/libs/portlets/dot-experiments/portlet/src/lib/old/dot-experiments-list/dot-experiments-list.component.html @@ -0,0 +1,45 @@ +@if (vm$ | async; as vm) { + +
+ @if (vm.experiments.length) { +
+ + +
+ + } @else { + + } +
+ @if (vm.addToBundleContentId) { + + } +} + + + + + + + + + + diff --git a/core-web/libs/portlets/dot-experiments/portlet/src/lib/old/dot-experiments-list/dot-experiments-list.component.spec.ts b/core-web/libs/portlets/dot-experiments/portlet/src/lib/old/dot-experiments-list/dot-experiments-list.component.spec.ts new file mode 100644 index 000000000000..17a823851a9d --- /dev/null +++ b/core-web/libs/portlets/dot-experiments/portlet/src/lib/old/dot-experiments-list/dot-experiments-list.component.spec.ts @@ -0,0 +1,430 @@ +import { createComponentFactory, mockProvider, Spectator } from '@openng/spectator/jest'; +import { MockComponent } from 'ng-mocks'; +import { BehaviorSubject, of } from 'rxjs'; + +import { provideHttpClient } from '@angular/common/http'; +import { provideHttpClientTesting } from '@angular/common/http/testing'; +import { fakeAsync, tick } from '@angular/core/testing'; +import { ActivatedRoute, Router } from '@angular/router'; + +import { ConfirmationService, MessageService } from 'primeng/api'; + +import { + DotExperimentsService, + DotHttpErrorManagerService, + DotMessageService, + DotFormatDateService +} from '@dotcms/data-access'; +import { + DotPushPublishDialogService, + LoginService, + DotcmsConfigService, + LoggerService +} from '@dotcms/dotcms-js'; +import { + ComponentStatus, + DotExperimentStatus, + DotExperimentsWithActions, + SidebarStatus +} from '@dotcms/dotcms-models'; +import { DotAddToBundleComponent } from '@dotcms/ui'; +import { getExperimentMock } from '@dotcms/utils-testing'; + +import { DotExperimentsListComponent } from './dot-experiments-list.component'; +import { DotExperimentsListStore, VmListExperiments } from './store/dot-experiments-list-store'; + +import { DotExperimentsStore } from '../dot-experiments-shell/store/dot-experiments.store'; + +const EXPERIMENT_MOCK_DRAFT = getExperimentMock(0); +const EXPERIMENT_MOCK_RUNNING = { + ...getExperimentMock(1), + status: DotExperimentStatus.RUNNING +}; +const EXPERIMENT_MOCK_ENDED = { + ...getExperimentMock(2), + status: DotExperimentStatus.ENDED +}; +const EXPERIMENT_MOCK_SCHEDULED = { + ...getExperimentMock(3), + status: DotExperimentStatus.SCHEDULED +}; + +describe('DotExperimentsListComponent', () => { + let spectator: Spectator; + let store: DotExperimentsListStore; + let router: jest.Mocked; + let vmSubject: BehaviorSubject; + + const createComponent = createComponentFactory({ + component: DotExperimentsListComponent, + imports: [DotExperimentsListComponent, MockComponent(DotAddToBundleComponent)], + providers: [ + provideHttpClient(), + provideHttpClientTesting(), + DotMessageService, + MessageService, + ConfirmationService, + DotHttpErrorManagerService, + mockProvider(DotExperimentsService), + mockProvider(DotPushPublishDialogService), + mockProvider(LoginService), + mockProvider(LoggerService), + mockProvider(DotFormatDateService), + mockProvider(DotcmsConfigService), + mockProvider(DotExperimentsStore, { + getPageId$: of('page-123'), + getPageTitle$: of('Test Page') + }), + mockProvider(Router, { + navigate: jest.fn().mockReturnValue(Promise.resolve(true)) + }), + mockProvider(ActivatedRoute, { + snapshot: { + params: { pageId: 'page-123' } + } + }) + ], + detectChanges: false + }); + + beforeEach(() => { + vmSubject = new BehaviorSubject({ + experiments: [], + isLoading: false, + experimentsFiltered: [], + filterStatus: [ + DotExperimentStatus.RUNNING, + DotExperimentStatus.SCHEDULED, + DotExperimentStatus.DRAFT, + DotExperimentStatus.ENDED + ], + sidebar: { + status: ComponentStatus.IDLE, + isOpen: false + } as SidebarStatus, + pageId: 'page-123', + pageTitle: 'Test Page', + addToBundleContentId: null + }); + + const mockStore = { + vm$: vmSubject.asObservable(), + setFilterStatus: jest.fn(), + openSidebar: jest.fn(), + closeSidebar: jest.fn() + }; + + spectator = createComponent({ + providers: [mockProvider(DotExperimentsListStore, mockStore)] + }); + + store = spectator.inject(DotExperimentsListStore, true); + router = spectator.inject(Router, true); + + spectator.detectChanges(); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should create', () => { + expect(spectator.component).toBeTruthy(); + }); + + describe('template - loading state', () => { + it('should show the skeleton component when is loading', () => { + // Setup: Loading state + vmSubject.next({ + experiments: [], + isLoading: true, + experimentsFiltered: [], + filterStatus: [DotExperimentStatus.DRAFT], + sidebar: { status: ComponentStatus.LOADING, isOpen: false }, + pageId: 'page-123', + pageTitle: 'Test Page', + addToBundleContentId: null + }); + + spectator.detectChanges(); + + const skeleton = spectator.query('dot-experiments-list-skeleton'); + const emptyContainer = spectator.query('dot-empty-container'); + const tableComponent = spectator.query('dot-experiments-list-table'); + + expect(skeleton).toBeTruthy(); + expect(emptyContainer).toBeNull(); + expect(tableComponent).toBeNull(); + }); + }); + + describe('template - empty state', () => { + it('should show the empty component when is not loading and no experiments', () => { + // Setup: Empty state + vmSubject.next({ + experiments: [], + isLoading: false, + experimentsFiltered: [], + filterStatus: [DotExperimentStatus.DRAFT], + sidebar: { status: ComponentStatus.IDLE, isOpen: false }, + pageId: 'page-123', + pageTitle: 'Test Page', + addToBundleContentId: null + }); + + spectator.detectChanges(); + + const skeleton = spectator.query('dot-experiments-list-skeleton'); + const emptyContainer = spectator.query('dot-empty-container'); + const tableComponent = spectator.query('dot-experiments-list-table'); + + expect(skeleton).toBeNull(); + expect(emptyContainer).toBeTruthy(); + expect(tableComponent).toBeNull(); + + // Verify empty container configuration + expect(emptyContainer?.textContent).toContain('experimentspage.not.experiments.founds'); + }); + }); + + describe('template - experiments list', () => { + it('should show the filters component and add experiment button exist when has experiments', () => { + // Setup: Has experiments + const experimentWithActions: DotExperimentsWithActions = { + ...EXPERIMENT_MOCK_DRAFT, + actionsItemsMenu: [] + }; + vmSubject.next({ + experiments: [EXPERIMENT_MOCK_DRAFT], + isLoading: false, + experimentsFiltered: [ + { + status: DotExperimentStatus.DRAFT, + experiments: [experimentWithActions] + } + ], + filterStatus: [DotExperimentStatus.DRAFT], + sidebar: { status: ComponentStatus.IDLE, isOpen: false }, + pageId: 'page-123', + pageTitle: 'Test Page', + addToBundleContentId: null + }); + + spectator.detectChanges(); + + const filterComponent = spectator.query('dot-experiments-status-filter'); + const addButton = spectator.query('[data-testId="add-experiment-button"]'); + const tableComponent = spectator.query('dot-experiments-list-table'); + const emptyContainer = spectator.query('dot-empty-container'); + const skeleton = spectator.query('dot-experiments-list-skeleton'); + + expect(filterComponent).toBeTruthy(); + expect(addButton).toBeTruthy(); + expect(tableComponent).toBeTruthy(); + expect(emptyContainer).toBeNull(); + expect(skeleton).toBeNull(); + }); + }); + + describe('sidebar interactions', () => { + it('should show the sidebar when click ADD EXPERIMENT', fakeAsync(() => { + // Setup: Has experiments + const experimentWithActions: DotExperimentsWithActions = { + ...EXPERIMENT_MOCK_DRAFT, + actionsItemsMenu: [] + }; + vmSubject.next({ + experiments: [EXPERIMENT_MOCK_DRAFT], + isLoading: false, + experimentsFiltered: [ + { + status: DotExperimentStatus.DRAFT, + experiments: [experimentWithActions] + } + ], + filterStatus: [DotExperimentStatus.DRAFT], + sidebar: { status: ComponentStatus.IDLE, isOpen: false }, + pageId: 'page-123', + pageTitle: 'Test Page', + addToBundleContentId: null + }); + + spectator.detectChanges(); + + const addButton = spectator.query('[data-testId="add-experiment-button"]'); + expect(addButton).toBeTruthy(); + + // Action: Click add experiment button + spectator.click(addButton as Element); + tick(); + + // Verify: Store method was called to open sidebar + expect(store.openSidebar).toHaveBeenCalled(); + })); + }); + + describe('navigation based on experiment status', () => { + it('should go to report Container if the experiment status is RUNNING', () => { + spectator.component.goToContainerAction(EXPERIMENT_MOCK_RUNNING); + + expect(router.navigate).toHaveBeenCalledWith( + [ + '/edit-page/experiments/', + EXPERIMENT_MOCK_RUNNING.pageId, + EXPERIMENT_MOCK_RUNNING.id, + 'reports' + ], + { + queryParams: { + mode: null, + variantName: null, + experimentId: null + }, + queryParamsHandling: 'merge' + } + ); + }); + + it('should go to report Container if the experiment status is ENDED', () => { + spectator.component.goToContainerAction(EXPERIMENT_MOCK_ENDED); + + expect(router.navigate).toHaveBeenCalledWith( + [ + '/edit-page/experiments/', + EXPERIMENT_MOCK_ENDED.pageId, + EXPERIMENT_MOCK_ENDED.id, + 'reports' + ], + { + queryParams: { + mode: null, + variantName: null, + experimentId: null + }, + queryParamsHandling: 'merge' + } + ); + }); + + it('should go to configuration Container if the experiment status is DRAFT', () => { + spectator.component.goToContainerAction(EXPERIMENT_MOCK_DRAFT); + + expect(router.navigate).toHaveBeenCalledWith( + [ + '/edit-page/experiments/', + EXPERIMENT_MOCK_DRAFT.pageId, + EXPERIMENT_MOCK_DRAFT.id, + 'configuration' + ], + { + queryParams: { + mode: null, + variantName: null, + experimentId: null + }, + queryParamsHandling: 'merge' + } + ); + }); + + it('should go to configuration Container if the experiment status is SCHEDULED', () => { + spectator.component.goToContainerAction(EXPERIMENT_MOCK_SCHEDULED); + + expect(router.navigate).toHaveBeenCalledWith( + [ + '/edit-page/experiments/', + EXPERIMENT_MOCK_SCHEDULED.pageId, + EXPERIMENT_MOCK_SCHEDULED.id, + 'configuration' + ], + { + queryParams: { + mode: null, + variantName: null, + experimentId: null + }, + queryParamsHandling: 'merge' + } + ); + }); + }); + + describe('add to bundle dialog', () => { + it('should show and remove add to bundle dialog', () => { + // Setup: Show add to bundle dialog + const experimentWithActions: DotExperimentsWithActions = { + ...EXPERIMENT_MOCK_DRAFT, + actionsItemsMenu: [] + }; + vmSubject.next({ + experiments: [EXPERIMENT_MOCK_DRAFT], + isLoading: false, + experimentsFiltered: [ + { + status: DotExperimentStatus.DRAFT, + experiments: [experimentWithActions] + } + ], + filterStatus: [DotExperimentStatus.DRAFT], + sidebar: { status: ComponentStatus.IDLE, isOpen: false }, + pageId: 'page-123', + pageTitle: 'Test Page', + addToBundleContentId: 'experiment-123' + }); + + spectator.detectChanges(); + + // Verify: Add to bundle dialog is shown + let addToBundleComponent = spectator.query('dot-add-to-bundle'); + expect(addToBundleComponent).toBeTruthy(); + + // Setup: Remove add to bundle dialog + vmSubject.next({ + experiments: [EXPERIMENT_MOCK_DRAFT], + isLoading: false, + experimentsFiltered: [ + { + status: DotExperimentStatus.DRAFT, + experiments: [experimentWithActions] + } + ], + filterStatus: [DotExperimentStatus.DRAFT], + sidebar: { status: ComponentStatus.IDLE, isOpen: false }, + pageId: 'page-123', + pageTitle: 'Test Page', + addToBundleContentId: null + }); + + spectator.detectChanges(); + + // Verify: Add to bundle dialog is removed + addToBundleComponent = spectator.query('dot-add-to-bundle'); + expect(addToBundleComponent).toBeNull(); + }); + }); + + describe('filter interactions', () => { + it('should call store method when filter is changed', () => { + const newFilterStatus = [DotExperimentStatus.RUNNING, DotExperimentStatus.ENDED]; + + spectator.component.selectedStatusFilter(newFilterStatus); + + expect(store.setFilterStatus).toHaveBeenCalledWith(newFilterStatus); + }); + }); + + describe('navigation - back button', () => { + it('should navigate to edit page content when goToBrowserBack is called', () => { + spectator.component.goToBrowserBack(); + + expect(router.navigate).toHaveBeenCalledWith(['edit-page/content'], { + queryParams: { + mode: null, + variantName: null, + experimentId: null + }, + queryParamsHandling: 'merge' + }); + }); + }); +}); diff --git a/core-web/libs/portlets/dot-experiments/portlet/src/lib/old/dot-experiments-list/dot-experiments-list.component.ts b/core-web/libs/portlets/dot-experiments/portlet/src/lib/old/dot-experiments-list/dot-experiments-list.component.ts new file mode 100644 index 000000000000..8b10007707d4 --- /dev/null +++ b/core-web/libs/portlets/dot-experiments/portlet/src/lib/old/dot-experiments-list/dot-experiments-list.component.ts @@ -0,0 +1,170 @@ +import { provideComponentStore } from '@ngrx/component-store'; +import { Observable } from 'rxjs'; + +import { AsyncPipe, NgTemplateOutlet } from '@angular/common'; +import { ChangeDetectionStrategy, Component, ComponentRef, inject, viewChild } from '@angular/core'; +import { Router } from '@angular/router'; + +import { ButtonModule } from 'primeng/button'; +import { ConfirmDialogModule } from 'primeng/confirmdialog'; + +import { tap } from 'rxjs/operators'; + +import { DotMessageService } from '@dotcms/data-access'; +import { + ComponentStatus, + CONFIGURATION_CONFIRM_DIALOG_KEY, + DotExperiment, + DotExperimentStatus, + ExperimentsStatusList, + SidebarStatus +} from '@dotcms/dotcms-models'; +import { + DotAddToBundleComponent, + DotDynamicDirective, + DotEmptyContainerComponent, + DotMessagePipe, + PrincipalConfiguration +} from '@dotcms/ui'; + +import { DotExperimentsCreateComponent } from './components/dot-experiments-create/dot-experiments-create.component'; +import { DotExperimentsListSkeletonComponent } from './components/dot-experiments-list-skeleton/dot-experiments-list-skeleton.component'; +import { DotExperimentsListTableComponent } from './components/dot-experiments-list-table/dot-experiments-list-table.component'; +import { DotExperimentsStatusFilterComponent } from './components/dot-experiments-status-filter/dot-experiments-status-filter.component'; +import { DotExperimentsListStore, VmListExperiments } from './store/dot-experiments-list-store'; + +import { DotExperimentsUiHeaderComponent } from '../shared/ui/dot-experiments-header/dot-experiments-ui-header.component'; + +@Component({ + selector: 'dot-experiments-list', + imports: [ + AsyncPipe, + NgTemplateOutlet, + DotExperimentsListSkeletonComponent, + DotExperimentsStatusFilterComponent, + DotExperimentsListTableComponent, + DotExperimentsUiHeaderComponent, + DotDynamicDirective, + DotMessagePipe, + ButtonModule, + ConfirmDialogModule, + DotAddToBundleComponent, + DotEmptyContainerComponent + ], + templateUrl: './dot-experiments-list.component.html', + providers: [provideComponentStore(DotExperimentsListStore)], + changeDetection: ChangeDetectionStrategy.OnPush, + host: { + class: 'h-full w-full flex flex-col pb-12' + } +}) +export class DotExperimentsListComponent { + private readonly dotExperimentsListStore = inject(DotExperimentsListStore); + private readonly router = inject(Router); + private readonly dotMessageService = inject(DotMessageService); + + sidebarHost = viewChild.required(DotDynamicDirective); + vm$: Observable = this.dotExperimentsListStore.vm$.pipe( + tap(({ sidebar }) => this.handleSidebar(sidebar)) + ); + statusOptionList = ExperimentsStatusList; + confirmDialogKey = CONFIGURATION_CONFIRM_DIALOG_KEY; + + protected readonly emptyConfiguration: PrincipalConfiguration = { + title: this.dotMessageService.get('experimentspage.not.experiments.founds'), + icon: 'pi-filter-fill rotate-180' + }; + private componentRef: ComponentRef; + + /** + * Update the list of selected statuses + * @param {Array} $event + * @returns void + * @memberof DotExperimentsListComponent + */ + selectedStatusFilter($event: Array): void { + this.dotExperimentsListStore.setFilterStatus($event); + } + + /** + * Add new experiment + * @returns void + * @memberof DotExperimentsListComponent + */ + addExperiment(): void { + this.dotExperimentsListStore.openSidebar(); + } + + /** + * Back to Edit Page / Content + * @returns void + * @memberof DotExperimentsShellComponent + */ + goToBrowserBack(): void { + this.router.navigate(['edit-page/content'], { + queryParams: { + mode: null, + variantName: null, + experimentId: null + }, + queryParamsHandling: 'merge' + }); + } + + /** + * Go to the experiment report or configuration depending on the experiment status + * @param {DotExperiment} experiment - Experiment to navigate to + * @returns void + * @memberof DotExperimentsShellComponent + */ + + goToContainerAction(experiment: DotExperiment) { + const route = ['/edit-page/experiments/', experiment.pageId, experiment.id]; + + if ( + experiment.status === DotExperimentStatus.RUNNING || + experiment.status === DotExperimentStatus.ENDED + ) { + route.push('reports'); + } else { + route.push('configuration'); + } + + this.router.navigate([...route], { + queryParams: { + mode: null, + variantName: null, + experimentId: null + }, + queryParamsHandling: 'merge' + }); + } + + private handleSidebar(status: SidebarStatus): void { + if (status && status.isOpen && status.status != ComponentStatus.SAVING) { + this.loadSidebarComponent(); + } else { + this.removeSidebarComponent(); + } + } + + private loadSidebarComponent(): void { + const sidebarHostRef = this.sidebarHost(); + if (sidebarHostRef) { + sidebarHostRef.viewContainerRef.clear(); + this.componentRef = + sidebarHostRef.viewContainerRef.createComponent( + DotExperimentsCreateComponent + ); + } + } + + private removeSidebarComponent(): void { + if (this.componentRef) { + const sidebarHostRef = this.sidebarHost(); + if (sidebarHostRef) { + sidebarHostRef.viewContainerRef.clear(); + } + } + } +} diff --git a/core-web/libs/portlets/dot-experiments/portlet/src/lib/dot-experiments-list/store/dot-experiments-list-store.spec.ts b/core-web/libs/portlets/dot-experiments/portlet/src/lib/old/dot-experiments-list/store/dot-experiments-list-store.spec.ts similarity index 100% rename from core-web/libs/portlets/dot-experiments/portlet/src/lib/dot-experiments-list/store/dot-experiments-list-store.spec.ts rename to core-web/libs/portlets/dot-experiments/portlet/src/lib/old/dot-experiments-list/store/dot-experiments-list-store.spec.ts diff --git a/core-web/libs/portlets/dot-experiments/portlet/src/lib/dot-experiments-list/store/dot-experiments-list-store.ts b/core-web/libs/portlets/dot-experiments/portlet/src/lib/old/dot-experiments-list/store/dot-experiments-list-store.ts similarity index 100% rename from core-web/libs/portlets/dot-experiments/portlet/src/lib/dot-experiments-list/store/dot-experiments-list-store.ts rename to core-web/libs/portlets/dot-experiments/portlet/src/lib/old/dot-experiments-list/store/dot-experiments-list-store.ts diff --git a/core-web/libs/portlets/dot-experiments/portlet/src/lib/dot-experiments-reports/components/dot-experiments-experiment-summary/dot-experiments-experiment-summary.component.html b/core-web/libs/portlets/dot-experiments/portlet/src/lib/old/dot-experiments-reports/components/dot-experiments-experiment-summary/dot-experiments-experiment-summary.component.html similarity index 100% rename from core-web/libs/portlets/dot-experiments/portlet/src/lib/dot-experiments-reports/components/dot-experiments-experiment-summary/dot-experiments-experiment-summary.component.html rename to core-web/libs/portlets/dot-experiments/portlet/src/lib/old/dot-experiments-reports/components/dot-experiments-experiment-summary/dot-experiments-experiment-summary.component.html diff --git a/core-web/libs/portlets/dot-experiments/portlet/src/lib/dot-experiments-reports/components/dot-experiments-experiment-summary/dot-experiments-experiment-summary.component.spec.ts b/core-web/libs/portlets/dot-experiments/portlet/src/lib/old/dot-experiments-reports/components/dot-experiments-experiment-summary/dot-experiments-experiment-summary.component.spec.ts similarity index 100% rename from core-web/libs/portlets/dot-experiments/portlet/src/lib/dot-experiments-reports/components/dot-experiments-experiment-summary/dot-experiments-experiment-summary.component.spec.ts rename to core-web/libs/portlets/dot-experiments/portlet/src/lib/old/dot-experiments-reports/components/dot-experiments-experiment-summary/dot-experiments-experiment-summary.component.spec.ts diff --git a/core-web/libs/portlets/dot-experiments/portlet/src/lib/dot-experiments-reports/components/dot-experiments-experiment-summary/dot-experiments-experiment-summary.component.ts b/core-web/libs/portlets/dot-experiments/portlet/src/lib/old/dot-experiments-reports/components/dot-experiments-experiment-summary/dot-experiments-experiment-summary.component.ts similarity index 100% rename from core-web/libs/portlets/dot-experiments/portlet/src/lib/dot-experiments-reports/components/dot-experiments-experiment-summary/dot-experiments-experiment-summary.component.ts rename to core-web/libs/portlets/dot-experiments/portlet/src/lib/old/dot-experiments-reports/components/dot-experiments-experiment-summary/dot-experiments-experiment-summary.component.ts diff --git a/core-web/libs/portlets/dot-experiments/portlet/src/lib/dot-experiments-reports/components/dot-experiments-report-daily-details/dot-experiments-report-daily-details.component.html b/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 similarity index 99% rename from core-web/libs/portlets/dot-experiments/portlet/src/lib/dot-experiments-reports/components/dot-experiments-report-daily-details/dot-experiments-report-daily-details.component.html rename to 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 index ef38aabebabe..f2ed7955f080 100644 --- a/core-web/libs/portlets/dot-experiments/portlet/src/lib/dot-experiments-reports/components/dot-experiments-report-daily-details/dot-experiments-report-daily-details.component.html +++ b/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 @@ -27,7 +27,7 @@ -
+
@if (row.isWinner) { } @else {
- show_chart + show_chart

{{ 'experiments.reports.chart.empty.title' | dm }}

{{ 'experiments.reports.chart.empty.description' | dm }} diff --git a/core-web/libs/portlets/dot-experiments/portlet/src/lib/dot-experiments-reports/components/dot-experiments-reports-chart/dot-experiments-reports-chart.component.spec.ts b/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 similarity index 100% rename from core-web/libs/portlets/dot-experiments/portlet/src/lib/dot-experiments-reports/components/dot-experiments-reports-chart/dot-experiments-reports-chart.component.spec.ts rename to 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 diff --git a/core-web/libs/portlets/dot-experiments/portlet/src/lib/dot-experiments-reports/components/dot-experiments-reports-chart/dot-experiments-reports-chart.component.ts b/core-web/libs/portlets/dot-experiments/portlet/src/lib/old/dot-experiments-reports/components/dot-experiments-reports-chart/dot-experiments-reports-chart.component.ts similarity index 100% rename from core-web/libs/portlets/dot-experiments/portlet/src/lib/dot-experiments-reports/components/dot-experiments-reports-chart/dot-experiments-reports-chart.component.ts rename to core-web/libs/portlets/dot-experiments/portlet/src/lib/old/dot-experiments-reports/components/dot-experiments-reports-chart/dot-experiments-reports-chart.component.ts diff --git a/core-web/libs/portlets/dot-experiments/portlet/src/lib/dot-experiments-reports/components/dot-experiments-reports-skeleton/dot-experiments-reports-skeleton.component.html b/core-web/libs/portlets/dot-experiments/portlet/src/lib/old/dot-experiments-reports/components/dot-experiments-reports-skeleton/dot-experiments-reports-skeleton.component.html similarity index 62% rename from core-web/libs/portlets/dot-experiments/portlet/src/lib/dot-experiments-reports/components/dot-experiments-reports-skeleton/dot-experiments-reports-skeleton.component.html rename to core-web/libs/portlets/dot-experiments/portlet/src/lib/old/dot-experiments-reports/components/dot-experiments-reports-skeleton/dot-experiments-reports-skeleton.component.html index e64bf7152284..50cb513fe920 100644 --- a/core-web/libs/portlets/dot-experiments/portlet/src/lib/dot-experiments-reports/components/dot-experiments-reports-skeleton/dot-experiments-reports-skeleton.component.html +++ b/core-web/libs/portlets/dot-experiments/portlet/src/lib/old/dot-experiments-reports/components/dot-experiments-reports-skeleton/dot-experiments-reports-skeleton.component.html @@ -1,5 +1,5 @@ -

+
@@ -7,8 +7,8 @@
-
+
diff --git a/core-web/libs/portlets/dot-experiments/portlet/src/lib/dot-experiments-reports/components/dot-experiments-reports-skeleton/dot-experiments-reports-skeleton.component.ts b/core-web/libs/portlets/dot-experiments/portlet/src/lib/old/dot-experiments-reports/components/dot-experiments-reports-skeleton/dot-experiments-reports-skeleton.component.ts similarity index 100% rename from core-web/libs/portlets/dot-experiments/portlet/src/lib/dot-experiments-reports/components/dot-experiments-reports-skeleton/dot-experiments-reports-skeleton.component.ts rename to core-web/libs/portlets/dot-experiments/portlet/src/lib/old/dot-experiments-reports/components/dot-experiments-reports-skeleton/dot-experiments-reports-skeleton.component.ts diff --git a/core-web/libs/portlets/dot-experiments/portlet/src/lib/dot-experiments-reports/dot-experiments-reports.component.html b/core-web/libs/portlets/dot-experiments/portlet/src/lib/old/dot-experiments-reports/dot-experiments-reports.component.html similarity index 100% rename from core-web/libs/portlets/dot-experiments/portlet/src/lib/dot-experiments-reports/dot-experiments-reports.component.html rename to core-web/libs/portlets/dot-experiments/portlet/src/lib/old/dot-experiments-reports/dot-experiments-reports.component.html diff --git a/core-web/libs/portlets/dot-experiments/portlet/src/lib/dot-experiments-reports/dot-experiments-reports.component.spec.ts b/core-web/libs/portlets/dot-experiments/portlet/src/lib/old/dot-experiments-reports/dot-experiments-reports.component.spec.ts similarity index 100% rename from core-web/libs/portlets/dot-experiments/portlet/src/lib/dot-experiments-reports/dot-experiments-reports.component.spec.ts rename to core-web/libs/portlets/dot-experiments/portlet/src/lib/old/dot-experiments-reports/dot-experiments-reports.component.spec.ts diff --git a/core-web/libs/portlets/dot-experiments/portlet/src/lib/dot-experiments-reports/dot-experiments-reports.component.ts b/core-web/libs/portlets/dot-experiments/portlet/src/lib/old/dot-experiments-reports/dot-experiments-reports.component.ts similarity index 100% rename from core-web/libs/portlets/dot-experiments/portlet/src/lib/dot-experiments-reports/dot-experiments-reports.component.ts rename to core-web/libs/portlets/dot-experiments/portlet/src/lib/old/dot-experiments-reports/dot-experiments-reports.component.ts diff --git a/core-web/libs/portlets/dot-experiments/portlet/src/lib/dot-experiments-reports/store/dot-experiments-reports-store.spec.ts b/core-web/libs/portlets/dot-experiments/portlet/src/lib/old/dot-experiments-reports/store/dot-experiments-reports-store.spec.ts similarity index 100% rename from core-web/libs/portlets/dot-experiments/portlet/src/lib/dot-experiments-reports/store/dot-experiments-reports-store.spec.ts rename to core-web/libs/portlets/dot-experiments/portlet/src/lib/old/dot-experiments-reports/store/dot-experiments-reports-store.spec.ts diff --git a/core-web/libs/portlets/dot-experiments/portlet/src/lib/dot-experiments-reports/store/dot-experiments-reports-store.ts b/core-web/libs/portlets/dot-experiments/portlet/src/lib/old/dot-experiments-reports/store/dot-experiments-reports-store.ts similarity index 100% rename from core-web/libs/portlets/dot-experiments/portlet/src/lib/dot-experiments-reports/store/dot-experiments-reports-store.ts rename to core-web/libs/portlets/dot-experiments/portlet/src/lib/old/dot-experiments-reports/store/dot-experiments-reports-store.ts diff --git a/core-web/libs/portlets/dot-experiments/portlet/src/lib/dot-experiments-shell/dot-experiments-shell.component.html b/core-web/libs/portlets/dot-experiments/portlet/src/lib/old/dot-experiments-shell/dot-experiments-shell.component.html similarity index 100% rename from core-web/libs/portlets/dot-experiments/portlet/src/lib/dot-experiments-shell/dot-experiments-shell.component.html rename to core-web/libs/portlets/dot-experiments/portlet/src/lib/old/dot-experiments-shell/dot-experiments-shell.component.html diff --git a/core-web/libs/portlets/dot-experiments/portlet/src/lib/dot-experiments-shell/dot-experiments-shell.component.spec.ts b/core-web/libs/portlets/dot-experiments/portlet/src/lib/old/dot-experiments-shell/dot-experiments-shell.component.spec.ts similarity index 100% rename from core-web/libs/portlets/dot-experiments/portlet/src/lib/dot-experiments-shell/dot-experiments-shell.component.spec.ts rename to core-web/libs/portlets/dot-experiments/portlet/src/lib/old/dot-experiments-shell/dot-experiments-shell.component.spec.ts diff --git a/core-web/libs/portlets/dot-experiments/portlet/src/lib/dot-experiments-shell/dot-experiments-shell.component.ts b/core-web/libs/portlets/dot-experiments/portlet/src/lib/old/dot-experiments-shell/dot-experiments-shell.component.ts similarity index 100% rename from core-web/libs/portlets/dot-experiments/portlet/src/lib/dot-experiments-shell/dot-experiments-shell.component.ts rename to core-web/libs/portlets/dot-experiments/portlet/src/lib/old/dot-experiments-shell/dot-experiments-shell.component.ts diff --git a/core-web/libs/portlets/dot-experiments/portlet/src/lib/dot-experiments-shell/store/dot-experiments.store.ts b/core-web/libs/portlets/dot-experiments/portlet/src/lib/old/dot-experiments-shell/store/dot-experiments.store.ts similarity index 100% rename from core-web/libs/portlets/dot-experiments/portlet/src/lib/dot-experiments-shell/store/dot-experiments.store.ts rename to core-web/libs/portlets/dot-experiments/portlet/src/lib/old/dot-experiments-shell/store/dot-experiments.store.ts diff --git a/core-web/libs/portlets/dot-experiments/portlet/src/lib/old/lib.routes.ts b/core-web/libs/portlets/dot-experiments/portlet/src/lib/old/lib.routes.ts new file mode 100644 index 000000000000..938ef5386a6d --- /dev/null +++ b/core-web/libs/portlets/dot-experiments/portlet/src/lib/old/lib.routes.ts @@ -0,0 +1,60 @@ +import { Routes } from '@angular/router'; + +import { ExperimentsConfigProperties } from '@dotcms/dotcms-models'; +import { DotExperimentsConfigResolver } from '@dotcms/portlets/dot-experiments/data-access'; +import { DotEnterpriseLicenseResolver, DotPushPublishEnvironmentsResolver } from '@dotcms/ui'; + +import { DotExperimentsAnalyticAppMisconfigurationComponent } from './dot-experiments-analytic-app-misconfiguration/dot-experiments-analytic-app-misconfiguration.component'; +import { DotExperimentsConfigurationComponent } from './dot-experiments-configuration/dot-experiments-configuration.component'; +import { DotExperimentsListComponent } from './dot-experiments-list/dot-experiments-list.component'; +import { DotExperimentsReportsComponent } from './dot-experiments-reports/dot-experiments-reports.component'; +import { DotExperimentsShellComponent } from './dot-experiments-shell/dot-experiments-shell.component'; +import { AnalyticsAppGuard } from './shared/guards/dot-experiments-analytic-app.guard'; + +export const dotExperimentsRoutes: Routes = [ + { + path: 'analytic-app-misconfiguration', + component: DotExperimentsAnalyticAppMisconfigurationComponent, + title: 'experiments.container.no-analytic-app-configured.title' + }, + { + path: ':pageId', + component: DotExperimentsShellComponent, + resolve: { + isEnterprise: DotEnterpriseLicenseResolver, + pushPublishEnvironments: DotPushPublishEnvironmentsResolver + }, + canActivateChild: [AnalyticsAppGuard], + children: [ + { + path: '', + title: 'experiment.container.list.title', + component: DotExperimentsListComponent + }, + { + path: ':experimentId/configuration', + title: 'experiment.container.configuration.title', + resolve: { + config: DotExperimentsConfigResolver + }, + data: { + experimentsConfigProps: [ + ExperimentsConfigProperties.EXPERIMENTS_MIN_DURATION, + ExperimentsConfigProperties.EXPERIMENTS_MAX_DURATION + ] + }, + component: DotExperimentsConfigurationComponent + }, + { + path: ':experimentId/reports', + title: 'experiment.container.report.title', + component: DotExperimentsReportsComponent + } + ] + }, + { + path: '**', + redirectTo: 'analytic-app-misconfiguration', + pathMatch: 'full' + } +]; diff --git a/core-web/libs/portlets/dot-experiments/portlet/src/lib/shared/dot-experiment.utils.ts b/core-web/libs/portlets/dot-experiments/portlet/src/lib/old/shared/dot-experiment.utils.ts similarity index 100% rename from core-web/libs/portlets/dot-experiments/portlet/src/lib/shared/dot-experiment.utils.ts rename to core-web/libs/portlets/dot-experiments/portlet/src/lib/old/shared/dot-experiment.utils.ts diff --git a/core-web/libs/portlets/dot-experiments/portlet/src/lib/shared/guards/dot-experiments-analytic-app.guard.ts b/core-web/libs/portlets/dot-experiments/portlet/src/lib/old/shared/guards/dot-experiments-analytic-app.guard.ts similarity index 100% rename from core-web/libs/portlets/dot-experiments/portlet/src/lib/shared/guards/dot-experiments-analytic-app.guard.ts rename to core-web/libs/portlets/dot-experiments/portlet/src/lib/old/shared/guards/dot-experiments-analytic-app.guard.ts diff --git a/core-web/libs/portlets/dot-experiments/portlet/src/lib/shared/ui/dot-experiment-options/components/dot-experiments-option-content-base-component/dot-experiments-option-content-base.component.ts b/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 similarity index 100% rename from core-web/libs/portlets/dot-experiments/portlet/src/lib/shared/ui/dot-experiment-options/components/dot-experiments-option-content-base-component/dot-experiments-option-content-base.component.ts rename to 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 diff --git a/core-web/libs/portlets/dot-experiments/portlet/src/lib/shared/ui/dot-experiment-options/directives/dot-experiment-option-content.directive.ts b/core-web/libs/portlets/dot-experiments/portlet/src/lib/old/shared/ui/dot-experiment-options/directives/dot-experiment-option-content.directive.ts similarity index 100% rename from core-web/libs/portlets/dot-experiments/portlet/src/lib/shared/ui/dot-experiment-options/directives/dot-experiment-option-content.directive.ts rename to core-web/libs/portlets/dot-experiments/portlet/src/lib/old/shared/ui/dot-experiment-options/directives/dot-experiment-option-content.directive.ts diff --git a/core-web/libs/portlets/dot-experiments/portlet/src/lib/shared/ui/dot-experiment-options/directives/dot-experiment-options-item.directive.ts b/core-web/libs/portlets/dot-experiments/portlet/src/lib/old/shared/ui/dot-experiment-options/directives/dot-experiment-options-item.directive.ts similarity index 100% rename from core-web/libs/portlets/dot-experiments/portlet/src/lib/shared/ui/dot-experiment-options/directives/dot-experiment-options-item.directive.ts rename to core-web/libs/portlets/dot-experiments/portlet/src/lib/old/shared/ui/dot-experiment-options/directives/dot-experiment-options-item.directive.ts diff --git a/core-web/libs/portlets/dot-experiments/portlet/src/lib/shared/ui/dot-experiment-options/dot-experiment-options.component.html b/core-web/libs/portlets/dot-experiments/portlet/src/lib/old/shared/ui/dot-experiment-options/dot-experiment-options.component.html similarity index 100% rename from core-web/libs/portlets/dot-experiments/portlet/src/lib/shared/ui/dot-experiment-options/dot-experiment-options.component.html rename to core-web/libs/portlets/dot-experiments/portlet/src/lib/old/shared/ui/dot-experiment-options/dot-experiment-options.component.html diff --git a/core-web/libs/portlets/dot-experiments/portlet/src/lib/shared/ui/dot-experiment-options/dot-experiment-options.component.scss b/core-web/libs/portlets/dot-experiments/portlet/src/lib/old/shared/ui/dot-experiment-options/dot-experiment-options.component.scss similarity index 100% rename from core-web/libs/portlets/dot-experiments/portlet/src/lib/shared/ui/dot-experiment-options/dot-experiment-options.component.scss rename to core-web/libs/portlets/dot-experiments/portlet/src/lib/old/shared/ui/dot-experiment-options/dot-experiment-options.component.scss diff --git a/core-web/libs/portlets/dot-experiments/portlet/src/lib/shared/ui/dot-experiment-options/dot-experiment-options.component.spec.ts b/core-web/libs/portlets/dot-experiments/portlet/src/lib/old/shared/ui/dot-experiment-options/dot-experiment-options.component.spec.ts similarity index 100% rename from core-web/libs/portlets/dot-experiments/portlet/src/lib/shared/ui/dot-experiment-options/dot-experiment-options.component.spec.ts rename to core-web/libs/portlets/dot-experiments/portlet/src/lib/old/shared/ui/dot-experiment-options/dot-experiment-options.component.spec.ts diff --git a/core-web/libs/portlets/dot-experiments/portlet/src/lib/shared/ui/dot-experiment-options/dot-experiment-options.component.ts b/core-web/libs/portlets/dot-experiments/portlet/src/lib/old/shared/ui/dot-experiment-options/dot-experiment-options.component.ts similarity index 100% rename from core-web/libs/portlets/dot-experiments/portlet/src/lib/shared/ui/dot-experiment-options/dot-experiment-options.component.ts rename to core-web/libs/portlets/dot-experiments/portlet/src/lib/old/shared/ui/dot-experiment-options/dot-experiment-options.component.ts diff --git a/core-web/libs/portlets/dot-experiments/portlet/src/lib/shared/ui/dot-experiments-details-table/dot-experiments-details-table.component.html b/core-web/libs/portlets/dot-experiments/portlet/src/lib/old/shared/ui/dot-experiments-details-table/dot-experiments-details-table.component.html similarity index 82% rename from core-web/libs/portlets/dot-experiments/portlet/src/lib/shared/ui/dot-experiments-details-table/dot-experiments-details-table.component.html rename to core-web/libs/portlets/dot-experiments/portlet/src/lib/old/shared/ui/dot-experiments-details-table/dot-experiments-details-table.component.html index 8667c0699244..d41d68e2f63a 100644 --- a/core-web/libs/portlets/dot-experiments/portlet/src/lib/shared/ui/dot-experiments-details-table/dot-experiments-details-table.component.html +++ b/core-web/libs/portlets/dot-experiments/portlet/src/lib/old/shared/ui/dot-experiments-details-table/dot-experiments-details-table.component.html @@ -3,7 +3,7 @@
@if ($title()) {
{{ $title() }} @@ -12,16 +12,16 @@ }
+ class="grid min-h-[35px] grid-cols-12 gap-4 rounded-md bg-gray-100 px-3 py-1 text-base font-bold text-gray-800">
-
+
@for (row of $data(); track row) { -
+
- table_chart + table_chart

{{ 'experiments.reports.summary.empty.title' | dm }}

diff --git a/core-web/libs/portlets/dot-experiments/portlet/src/lib/shared/ui/dot-experiments-details-table/dot-experiments-details-table.component.spec.ts b/core-web/libs/portlets/dot-experiments/portlet/src/lib/old/shared/ui/dot-experiments-details-table/dot-experiments-details-table.component.spec.ts similarity index 100% rename from core-web/libs/portlets/dot-experiments/portlet/src/lib/shared/ui/dot-experiments-details-table/dot-experiments-details-table.component.spec.ts rename to core-web/libs/portlets/dot-experiments/portlet/src/lib/old/shared/ui/dot-experiments-details-table/dot-experiments-details-table.component.spec.ts diff --git a/core-web/libs/portlets/dot-experiments/portlet/src/lib/shared/ui/dot-experiments-details-table/dot-experiments-details-table.component.ts b/core-web/libs/portlets/dot-experiments/portlet/src/lib/old/shared/ui/dot-experiments-details-table/dot-experiments-details-table.component.ts similarity index 100% rename from core-web/libs/portlets/dot-experiments/portlet/src/lib/shared/ui/dot-experiments-details-table/dot-experiments-details-table.component.ts rename to core-web/libs/portlets/dot-experiments/portlet/src/lib/old/shared/ui/dot-experiments-details-table/dot-experiments-details-table.component.ts diff --git a/core-web/libs/portlets/dot-experiments/portlet/src/lib/shared/ui/dot-experiments-goal-configuration-reach-page/dot-experiments-goal-configuration-reach-page.component.html b/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 similarity index 86% rename from core-web/libs/portlets/dot-experiments/portlet/src/lib/shared/ui/dot-experiments-goal-configuration-reach-page/dot-experiments-goal-configuration-reach-page.component.html rename to 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 index 3cc34c443281..1bb6fc4dd958 100644 --- a/core-web/libs/portlets/dot-experiments/portlet/src/lib/shared/ui/dot-experiments-goal-configuration-reach-page/dot-experiments-goal-configuration-reach-page.component.html +++ b/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 @@ -1,5 +1,5 @@
-
+

{{ 'experiments.goal.reach_page.form.conditions.label' | dm }}

@@ -7,9 +7,9 @@

@for (condition of conditionsFormArray.controls; track condition; let i = $index) {
-
+
@@ -28,9 +28,9 @@

-
+
@@ -49,9 +49,9 @@

-
+
diff --git a/core-web/libs/portlets/dot-experiments/portlet/src/lib/shared/ui/dot-experiments-goal-configuration-reach-page/dot-experiments-goal-configuration-reach-page.component.spec.ts b/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.spec.ts similarity index 100% rename from core-web/libs/portlets/dot-experiments/portlet/src/lib/shared/ui/dot-experiments-goal-configuration-reach-page/dot-experiments-goal-configuration-reach-page.component.spec.ts rename to 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.spec.ts diff --git a/core-web/libs/portlets/dot-experiments/portlet/src/lib/shared/ui/dot-experiments-goal-configuration-reach-page/dot-experiments-goal-configuration-reach-page.component.ts b/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 similarity index 100% rename from core-web/libs/portlets/dot-experiments/portlet/src/lib/shared/ui/dot-experiments-goal-configuration-reach-page/dot-experiments-goal-configuration-reach-page.component.ts rename to 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 diff --git a/core-web/libs/portlets/dot-experiments/portlet/src/lib/shared/ui/dot-experiments-goal-configuration-url-parameter-component/dot-experiments-goal-configuration-url-parameter-component.component.html b/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 similarity index 87% rename from core-web/libs/portlets/dot-experiments/portlet/src/lib/shared/ui/dot-experiments-goal-configuration-url-parameter-component/dot-experiments-goal-configuration-url-parameter-component.component.html rename to 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 index 08c1d6934cb4..59f49bfe31a9 100644 --- a/core-web/libs/portlets/dot-experiments/portlet/src/lib/shared/ui/dot-experiments-goal-configuration-url-parameter-component/dot-experiments-goal-configuration-url-parameter-component.component.html +++ b/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 @@ -1,5 +1,5 @@
-
+

{{ 'experiments.goal.reach_page.form.conditions.label' | dm }}

@@ -7,9 +7,9 @@

@for (condition of conditionsFormArray.controls; track condition; let i = $index) {
-
+
-
+
-
+
diff --git a/core-web/libs/portlets/dot-experiments/portlet/src/lib/shared/ui/dot-experiments-goal-configuration-url-parameter-component/dot-experiments-goal-configuration-url-parameter-component.component.spec.ts b/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.spec.ts similarity index 100% rename from core-web/libs/portlets/dot-experiments/portlet/src/lib/shared/ui/dot-experiments-goal-configuration-url-parameter-component/dot-experiments-goal-configuration-url-parameter-component.component.spec.ts rename to 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.spec.ts diff --git a/core-web/libs/portlets/dot-experiments/portlet/src/lib/shared/ui/dot-experiments-goal-configuration-url-parameter-component/dot-experiments-goal-configuration-url-parameter-component.component.ts b/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 similarity index 100% rename from core-web/libs/portlets/dot-experiments/portlet/src/lib/shared/ui/dot-experiments-goal-configuration-url-parameter-component/dot-experiments-goal-configuration-url-parameter-component.component.ts rename to 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 diff --git a/core-web/libs/portlets/dot-experiments/portlet/src/lib/shared/ui/dot-experiments-goals-coming-soon/dot-experiments-goals-coming-soon.component.html b/core-web/libs/portlets/dot-experiments/portlet/src/lib/old/shared/ui/dot-experiments-goals-coming-soon/dot-experiments-goals-coming-soon.component.html similarity index 100% rename from core-web/libs/portlets/dot-experiments/portlet/src/lib/shared/ui/dot-experiments-goals-coming-soon/dot-experiments-goals-coming-soon.component.html rename to core-web/libs/portlets/dot-experiments/portlet/src/lib/old/shared/ui/dot-experiments-goals-coming-soon/dot-experiments-goals-coming-soon.component.html diff --git a/core-web/libs/portlets/dot-experiments/portlet/src/lib/shared/ui/dot-experiments-goals-coming-soon/dot-experiments-goals-coming-soon.component.spec.ts b/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 similarity index 100% rename from core-web/libs/portlets/dot-experiments/portlet/src/lib/shared/ui/dot-experiments-goals-coming-soon/dot-experiments-goals-coming-soon.component.spec.ts rename to 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 diff --git a/core-web/libs/portlets/dot-experiments/portlet/src/lib/shared/ui/dot-experiments-goals-coming-soon/dot-experiments-goals-coming-soon.component.ts b/core-web/libs/portlets/dot-experiments/portlet/src/lib/old/shared/ui/dot-experiments-goals-coming-soon/dot-experiments-goals-coming-soon.component.ts similarity index 100% rename from core-web/libs/portlets/dot-experiments/portlet/src/lib/shared/ui/dot-experiments-goals-coming-soon/dot-experiments-goals-coming-soon.component.ts rename to core-web/libs/portlets/dot-experiments/portlet/src/lib/old/shared/ui/dot-experiments-goals-coming-soon/dot-experiments-goals-coming-soon.component.ts diff --git a/core-web/libs/portlets/dot-experiments/portlet/src/lib/shared/ui/dot-experiments-header/dot-experiments-ui-header.component.html b/core-web/libs/portlets/dot-experiments/portlet/src/lib/old/shared/ui/dot-experiments-header/dot-experiments-ui-header.component.html similarity index 100% rename from core-web/libs/portlets/dot-experiments/portlet/src/lib/shared/ui/dot-experiments-header/dot-experiments-ui-header.component.html rename to core-web/libs/portlets/dot-experiments/portlet/src/lib/old/shared/ui/dot-experiments-header/dot-experiments-ui-header.component.html diff --git a/core-web/libs/portlets/dot-experiments/portlet/src/lib/shared/ui/dot-experiments-header/dot-experiments-ui-header.component.spec.ts b/core-web/libs/portlets/dot-experiments/portlet/src/lib/old/shared/ui/dot-experiments-header/dot-experiments-ui-header.component.spec.ts similarity index 100% rename from core-web/libs/portlets/dot-experiments/portlet/src/lib/shared/ui/dot-experiments-header/dot-experiments-ui-header.component.spec.ts rename to core-web/libs/portlets/dot-experiments/portlet/src/lib/old/shared/ui/dot-experiments-header/dot-experiments-ui-header.component.spec.ts diff --git a/core-web/libs/portlets/dot-experiments/portlet/src/lib/shared/ui/dot-experiments-header/dot-experiments-ui-header.component.ts b/core-web/libs/portlets/dot-experiments/portlet/src/lib/old/shared/ui/dot-experiments-header/dot-experiments-ui-header.component.ts similarity index 100% rename from core-web/libs/portlets/dot-experiments/portlet/src/lib/shared/ui/dot-experiments-header/dot-experiments-ui-header.component.ts rename to core-web/libs/portlets/dot-experiments/portlet/src/lib/old/shared/ui/dot-experiments-header/dot-experiments-ui-header.component.ts diff --git a/core-web/libs/portlets/dot-experiments/portlet/src/lib/shared/ui/dot-experiments-inline-edit-text/dot-experiments-inline-edit-text.component.html b/core-web/libs/portlets/dot-experiments/portlet/src/lib/old/shared/ui/dot-experiments-inline-edit-text/dot-experiments-inline-edit-text.component.html similarity index 97% rename from core-web/libs/portlets/dot-experiments/portlet/src/lib/shared/ui/dot-experiments-inline-edit-text/dot-experiments-inline-edit-text.component.html rename to core-web/libs/portlets/dot-experiments/portlet/src/lib/old/shared/ui/dot-experiments-inline-edit-text/dot-experiments-inline-edit-text.component.html index b90eba8e84e4..22990bf8fe4a 100644 --- a/core-web/libs/portlets/dot-experiments/portlet/src/lib/shared/ui/dot-experiments-inline-edit-text/dot-experiments-inline-edit-text.component.html +++ b/core-web/libs/portlets/dot-experiments/portlet/src/lib/old/shared/ui/dot-experiments-inline-edit-text/dot-experiments-inline-edit-text.component.html @@ -24,7 +24,7 @@
- + ( + Object.values(GOAL_TYPES).map((goal) => [goal, GOALS_METADATA_MAP[goal].label]) +); + +/** + * Statuses hidden while the filter is empty. Archived experiments are opt-in — an unfiltered + * list means "everything still in play", and archived rows would otherwise pad it out + * permanently with work nobody is looking at. Selecting ARCHIVED shows them. + */ +export const OPT_IN_STATUSES: readonly DotExperimentStatus[] = [DotExperimentStatus.ARCHIVED]; + +/** + * Copied from `DotExperimentsUiHeaderComponent` so a status looks identical here and in the + * UVE header. Duplicated rather than imported: the header is legacy code left untouched. + */ +export const STATUS_SEVERITIES: Record = { + [DotExperimentStatus.RUNNING]: 'success', + [DotExperimentStatus.SCHEDULED]: 'info', + [DotExperimentStatus.DRAFT]: 'warn', + [DotExperimentStatus.ENDED]: 'info', + [DotExperimentStatus.ARCHIVED]: 'secondary' +}; + +/** Existing lowercase i18n keys (`draft`, `running`, …) already declared by `ExperimentsStatusList`. */ +export const STATUS_LABEL_KEYS = new Map( + ExperimentsStatusList.map(({ value, label }) => [value, label]) +); + +/** Lifetime of the success toasts pushed after a row action. */ +export const SUCCESS_MESSAGE_LIFE = 5000; + +export const ROWS_PER_PAGE_OPTIONS = [10, 25, 50]; + +/** Placeholder rows drawn while the first page is still loading. */ +export const SKELETON_ROW_COUNT = 5; + +/** + * Placeholder rows fed to the table while the first page loads. + * + * Empty objects rather than `null`: PrimeNG's table skips falsy rows entirely, so a + * `null`-filled array renders no `` at all and the skeleton never appears. Their fields are + * never read — the body template branches on the loading signal and renders skeleton cells. + */ +export const SKELETON_ROWS: ExperimentRow[] = Array.from( + { length: SKELETON_ROW_COUNT }, + () => ({}) as ExperimentRow +); + +/** One skeleton cell per table column. */ +export const SKELETON_COLUMNS = Array.from({ length: 8 }, (_, index) => index); + +/** Placeholder rendered in the Goal column when no goal is configured. */ +export const NO_GOAL_PLACEHOLDER = '—'; + +/** Height of the status filter's option list before it scrolls. */ +export const LISTBOX_SCROLL_HEIGHT = '320px'; + +/** Idle time before a search term is applied, in ms. */ +export const SEARCH_DEBOUNCE_MS = 300; + +/** + * Multiplier applied to the page-lookup limit. + * + * Elasticsearch holds one document per identifier *and* language, so a multilingual site returns + * several documents per page. Limiting to the number of pages therefore truncated the response at + * HTTP 200, and every page that fell off the end took its experiments with it — the site filter + * fails closed, so they vanished from the list with no error to show for it. + * + * Any language's document carries the `host` and `url` this lookup needs, and duplicates collapse + * by identifier, so over-asking is harmless. This is deliberately far above any realistic language + * count rather than tuned; the shortfall check in the store is what catches it being wrong. + */ +export const PAGE_LOOKUP_LANGUAGE_HEADROOM = 25; diff --git a/core-web/libs/portlets/dot-experiments/portlet/src/lib/shared/models.ts b/core-web/libs/portlets/dot-experiments/portlet/src/lib/shared/models.ts new file mode 100644 index 000000000000..139a6ab9341d --- /dev/null +++ b/core-web/libs/portlets/dot-experiments/portlet/src/lib/shared/models.ts @@ -0,0 +1,70 @@ +import { + AllowedActionsByExperimentStatus, + DotExperiment, + DotExperimentStatus, + GOAL_TYPES +} from '@dotcms/dotcms-models'; + +/** Every action of the list gated by `AllowedActionsByExperimentStatus`. */ +export type ExperimentListAction = keyof typeof AllowedActionsByExperimentStatus; + +/** Sort direction used by the experiments list. */ +export type DotExperimentsListSortDirection = 'ASC' | 'DESC'; + +/** + * Page data resolved for an experiment's `pageId`. `DotExperiment` carries no host, so + * `host` is the only way to scope the experiments list to the current site, and `url` is + * the path rendered in the Page column. + */ +export interface DotExperimentPageInfo { + url: string; + host: string; +} + +/** The URL-backed slice of the list view: filter, status selection, paging and sort. */ +export interface DotExperimentsListViewState { + filter: string; + selectedStatuses: DotExperimentStatus[]; + selectedGoals: GOAL_TYPES[]; + page: number; + perPage: number; + orderBy: string; + direction: DotExperimentsListSortDirection; +} + +/** Paging change emitted by the table paginator. */ +export interface DotExperimentsListPageChange { + page: number; + perPage: number; +} + +/** Sort change emitted by the table header. */ +export interface DotExperimentsListSortChange { + orderBy: string; + direction: DotExperimentsListSortDirection; +} + +/** `warn` (not `warning`) is PrimeNG's spelling — anything else yields no `p-tag-*` class. */ +export type TagSeverity = 'success' | 'info' | 'warn' | 'secondary'; + +/** A table row: the experiment plus everything the template would otherwise have to derive. */ +export interface ExperimentRow { + experiment: DotExperiment; + pagePath: string; + /** i18n key of the primary goal type, or `null` when the experiment has no goal. */ + goalLabelKey: string | null; + variants: number; + schedule: string; + statusSeverity: TagSeverity; + statusLabelKey: string; +} + +/** One selectable value inside a `dot-experiment-list-filter` popover. */ +export interface ExperimentFilterOption { + value: string; + /** Translated, human-readable name. */ + label: string; + /** Count for this value, stringified for the list item's secondary slot. */ + count: string; + testId: string; +} diff --git a/core-web/libs/portlets/dot-experiments/portlet/src/lib/store/dot-experiments-api.events.ts b/core-web/libs/portlets/dot-experiments/portlet/src/lib/store/dot-experiments-api.events.ts new file mode 100644 index 000000000000..eb50ef136327 --- /dev/null +++ b/core-web/libs/portlets/dot-experiments/portlet/src/lib/store/dot-experiments-api.events.ts @@ -0,0 +1,47 @@ +import { type } from '@ngrx/signals'; +import { eventGroup } from '@ngrx/signals/events'; + +import { DotExperiment, HealthStatusTypes } from '@dotcms/dotcms-models'; + +import { DotExperimentPageInfo } from '../shared/models'; + +/** + * What the backend answered: every event here is dispatched from a store event handler once a + * request settles, never by a component. The matching `…Requested` events belong to + * `dotExperimentsListPageEvents`. + * + * `…Succeeded` echoes the whole `DotExperiment` back for the CRUD flows so the component can + * name the experiment in its toast without re-reading the list. + */ +export const dotExperimentsApiEvents = eventGroup({ + source: 'Experiments API', + events: { + // Analytics health gate + healthCheckSucceeded: type(), + healthCheckFailed: type(), + + // Load + listSucceeded: type(), + listFailed: type(), + + // Page resolution (bulk lookup of the distinct pageIds) + pageInfoSucceeded: type>(), + pageInfoFailed: type(), + + // CRUD + archiveSucceeded: type(), + archiveFailed: type(), + + deleteSucceeded: type(), + deleteFailed: type(), + + endSucceeded: type(), + endFailed: type(), + + abortSucceeded: type(), + abortFailed: type(), + + cancelScheduleSucceeded: type(), + cancelScheduleFailed: type() + } +}); diff --git a/core-web/libs/portlets/dot-experiments/portlet/src/lib/store/dot-experiments-list-page.events.ts b/core-web/libs/portlets/dot-experiments/portlet/src/lib/store/dot-experiments-list-page.events.ts new file mode 100644 index 000000000000..18906e08edb5 --- /dev/null +++ b/core-web/libs/portlets/dot-experiments/portlet/src/lib/store/dot-experiments-list-page.events.ts @@ -0,0 +1,54 @@ +import { type } from '@ngrx/signals'; +import { eventGroup } from '@ngrx/signals/events'; + +import { DotExperiment, DotExperimentStatus, GOAL_TYPES } from '@dotcms/dotcms-models'; + +import { + DotExperimentsListPageChange, + DotExperimentsListSortChange, + DotExperimentsListViewState +} from '../shared/models'; + +/** + * What the list page asks for: user intent and lifecycle, never a result. + * + * Every event here is dispatched by the page itself — a click, a keystroke, a URL change, the + * component coming up. What comes *back* lives in `dotExperimentsApiEvents`, so the two halves + * of an async flow are never confused for one another: the page states an intent + * (`archiveExperiment`), the API reports the outcome (`archiveSucceeded` / `archiveFailed`). + * + * Hence the imperative names: these are commands, not results. CRUD commands carry the whole + * `DotExperiment` (not just the id) because the component needs the name for its confirmation + * and toast copy. + * + * Confirmations and toasts are the component's job — the store never opens UI. + */ +export const dotExperimentsListPageEvents = eventGroup({ + source: 'Experiments List Page', + events: { + // Analytics health gate: runs before anything is fetched, since a misconfigured + // Analytics app makes the whole list meaningless. + checkHealth: type(), + + // Load + loadExperiments: type(), + + // View state (URL-backed) + filterChanged: type(), + statusesChanged: type(), + goalsChanged: type(), + pageChanged: type(), + sortChanged: type(), + hydratedFromUrl: type(), + + // Site + siteChanged: type(), + + // CRUD intent, already confirmed in the component + archiveExperiment: type(), + deleteExperiment: type(), + endExperiment: type(), + abortExperiment: type(), + cancelScheduleExperiment: type() + } +}); diff --git a/core-web/libs/portlets/dot-experiments/portlet/src/lib/store/dot-experiments-list.store.spec.ts b/core-web/libs/portlets/dot-experiments/portlet/src/lib/store/dot-experiments-list.store.spec.ts new file mode 100644 index 000000000000..51e9686cd107 --- /dev/null +++ b/core-web/libs/portlets/dot-experiments/portlet/src/lib/store/dot-experiments-list.store.spec.ts @@ -0,0 +1,1029 @@ +import { Dispatcher, EventCreator, provideDispatcher } from '@ngrx/signals/events'; +import { createServiceFactory, mockProvider, SpectatorService } from '@openng/spectator/jest'; +import { NEVER, of, throwError } from 'rxjs'; + +import { Location } from '@angular/common'; +import { HttpErrorResponse } from '@angular/common/http'; +import { signal, WritableSignal } from '@angular/core'; +import { ActivatedRoute, Params, provideRouter } from '@angular/router'; + +import { + DotContentSearchService, + DotExperimentsService, + DotHttpErrorManagerService +} from '@dotcms/data-access'; +import { + ComponentStatus, + DotCMSContentlet, + DotExperiment, + DotExperimentStatus, + GOAL_TYPES, + HealthStatusTypes, + TrafficProportionTypes +} from '@dotcms/dotcms-models'; +import { GlobalStore } from '@dotcms/store'; + +import { dotExperimentsListPageEvents } from './dot-experiments-list-page.events'; +import { DotExperimentsListStore } from './dot-experiments-list.store'; + +import { + DEFAULT_EXPERIMENTS_LIST_ORDER_BY, + DEFAULT_EXPERIMENTS_LIST_GOALS, + DEFAULT_EXPERIMENTS_LIST_PAGE, + DEFAULT_EXPERIMENTS_LIST_PER_PAGE, + DEFAULT_EXPERIMENTS_LIST_STATUSES, + PAGE_LOOKUP_LANGUAGE_HEADROOM +} from '../shared/constants'; + +const CURRENT_SITE_ID = 'site-1'; +const OTHER_SITE_ID = 'site-2'; + +const buildExperiment = (experiment: Partial): DotExperiment => ({ + id: 'exp-id', + pageId: 'page-1', + name: 'Experiment', + description: 'An experiment', + status: DotExperimentStatus.DRAFT, + readyToStart: false, + archived: false, + trafficProportion: { type: TrafficProportionTypes.SPLIT_EVENLY, variants: [] }, + trafficAllocation: 100, + scheduling: null, + creationDate: new Date('2026-01-01T00:00:00.000Z'), + modDate: 0, + goals: null, + ...experiment +}); + +const buildPageContentlet = (identifier: string, url: string, host: string): DotCMSContentlet => + ({ identifier, url, host }) as unknown as DotCMSContentlet; + +/** Goal, keyed by level exactly as the API returns it; only `primary` is ever shown. */ +const buildGoals = (type: GOAL_TYPES) => + ({ primary: { type, conditions: [] } }) as unknown as DotExperiment['goals']; + +const EXPERIMENT_DRAFT = buildExperiment({ + id: 'exp-draft', + pageId: 'page-1', + name: 'Alpha campaign', + description: 'Checkout funnel rework', + status: DotExperimentStatus.DRAFT, + goals: buildGoals(GOAL_TYPES.BOUNCE_RATE), + modDate: 300 +}); + +const EXPERIMENT_RUNNING = buildExperiment({ + id: 'exp-running', + pageId: 'page-2', + name: 'Beta rollout', + description: 'Pricing page headline', + status: DotExperimentStatus.RUNNING, + goals: buildGoals(GOAL_TYPES.EXIT_RATE), + modDate: 100 +}); + +/** Lives on a page of another site: must never reach the list. */ +const EXPERIMENT_OTHER_SITE = buildExperiment({ + id: 'exp-other-site', + pageId: 'page-3', + name: 'Gamma remote', + status: DotExperimentStatus.DRAFT, + modDate: 200 +}); + +const EXPERIMENT_ARCHIVED = buildExperiment({ + id: 'exp-archived', + pageId: 'page-1', + name: 'Delta retired', + status: DotExperimentStatus.ARCHIVED, + archived: true, + modDate: 400 +}); + +/** Its `pageId` is not returned by the page lookup: unresolvable, so it must be dropped. */ +const EXPERIMENT_ORPHAN = buildExperiment({ + id: 'exp-orphan', + pageId: 'page-orphan', + name: 'Epsilon orphan', + status: DotExperimentStatus.DRAFT, + modDate: 500 +}); + +const EXPERIMENTS: DotExperiment[] = [ + EXPERIMENT_DRAFT, + EXPERIMENT_RUNNING, + EXPERIMENT_OTHER_SITE, + EXPERIMENT_ARCHIVED, + EXPERIMENT_ORPHAN +]; + +const PAGE_SEARCH_RESULT = { + jsonObjectView: { + contentlets: [ + buildPageContentlet('page-1', '/home', CURRENT_SITE_ID), + buildPageContentlet('page-2', '/checkout', CURRENT_SITE_ID), + buildPageContentlet('page-3', '/remote', OTHER_SITE_ID) + ] + } +}; + +describe('DotExperimentsListStore', () => { + let spectator: SpectatorService>; + let store: InstanceType; + let dispatcher: Dispatcher; + let httpErrorManager: jest.Mocked; + + const healthCheck = jest.fn(); + const getAllUnfiltered = jest.fn(); + const archive = jest.fn(); + const remove = jest.fn(); + const stop = jest.fn(); + const cancelSchedule = jest.fn(); + const contentSearchGet = jest.fn(); + const locationSubscribe = jest.fn(); + const locationGo = jest.fn(); + const locationPath = jest.fn(); + + let currentSiteId: WritableSignal; + let queryParams: Params; + + const createService = createServiceFactory({ + service: DotExperimentsListStore, + providers: [ + // `Dispatcher`/`Events` are `providedIn: 'platform'`, so they outlive TestBed resets + // and a store from a previous test would keep reacting to this test's events. + provideDispatcher(), + mockProvider(DotExperimentsService, { + healthCheck, + getAllUnfiltered, + archive, + delete: remove, + stop, + cancelSchedule + }), + mockProvider(DotContentSearchService, { get: contentSearchGet }), + mockProvider(DotHttpErrorManagerService), + mockProvider(GlobalStore, { + get currentSiteId() { + return currentSiteId; + } + }), + // The store subscribes to Location (popstate re-hydration) and writes the view + // state back through it. A real Router builds the URL, so the write-back tests + // exercise the actual `createUrlTree` merge rather than a stubbed string. + provideRouter([]), + mockProvider(Location, { + subscribe: locationSubscribe, + go: locationGo, + path: locationPath + }), + { + provide: ActivatedRoute, + useValue: { + snapshot: { + get queryParams() { + return queryParams; + } + } + } + } + ] + }); + + /** + * Creates the store. Called from the tests (not from a global `beforeEach`) because the + * whole load flow runs in `onInit`, so every arrangement has to be in place first. + */ + const initStore = () => { + spectator = createService(); + store = spectator.service; + dispatcher = spectator.inject(Dispatcher); + httpErrorManager = spectator.inject( + DotHttpErrorManagerService + ) as jest.Mocked; + spectator.flushEffects(); + }; + + const httpError = (status: number) => new HttpErrorResponse({ status }); + + beforeEach(() => { + jest.resetAllMocks(); + + healthCheck.mockReturnValue(of(HealthStatusTypes.OK)); + getAllUnfiltered.mockReturnValue(of(EXPERIMENTS)); + contentSearchGet.mockReturnValue(of(PAGE_SEARCH_RESULT)); + archive.mockReturnValue(of({})); + remove.mockReturnValue(of({})); + stop.mockReturnValue(of({})); + cancelSchedule.mockReturnValue(of({})); + locationSubscribe.mockReturnValue({ unsubscribe: jest.fn() }); + // Whatever the effect computes will differ from this, so a write always happens unless + // a test says otherwise. + locationPath.mockReturnValue('/stale'); + + currentSiteId = signal(CURRENT_SITE_ID); + queryParams = {}; + }); + + describe('initial load', () => { + it('should request the list once on init', () => { + initStore(); + + expect(getAllUnfiltered).toHaveBeenCalledTimes(1); + }); + + it('should look the distinct page ids up in bulk once the list arrives', () => { + initStore(); + + expect(contentSearchGet).toHaveBeenCalledWith({ + query: '+contentType:htmlpageasset +working:true +identifier:(page-1 page-2 page-3 page-orphan)', + // Not the page count: ES holds a document per identifier *and* language, so a + // page-count limit truncated the response on any multilingual site. + limit: 4 * PAGE_LOOKUP_LANGUAGE_HEADROOM + }); + }); + + it('should warn when the lookup does not cover every page asked for', () => { + const warn = jest.spyOn(console, 'warn').mockImplementation(); + // page-2, page-3 and page-orphan are requested but absent from the response. + contentSearchGet.mockReturnValue( + of({ + jsonObjectView: { + contentlets: [buildPageContentlet('page-1', '/home', CURRENT_SITE_ID)] + } + }) + ); + + initStore(); + + // Unresolved pages are dropped by the site filter, which fails closed — so without + // this the list just comes back short, with a total that agrees with it. + expect(warn).toHaveBeenCalledWith( + expect.stringContaining('resolved 1 of 4 pages'), + expect.arrayContaining(['page-2', 'page-3', 'page-orphan']) + ); + warn.mockRestore(); + }); + + it('should settle out of loading when no experiment has a resolvable pageId', () => { + getAllUnfiltered.mockReturnValue(of([buildExperiment({ id: 'no-page', pageId: '' })])); + + initStore(); + + // The lookup is skipped, so nothing else would move the status — it used to sit on + // `loading` forever, showing skeletons with no error and no way out. + expect(store.status()).toBe(ComponentStatus.LOADED); + expect(contentSearchGet).not.toHaveBeenCalled(); + }); + + it('should store the experiments and the resolved page info and end up loaded', () => { + initStore(); + + expect(store.experiments()).toEqual(EXPERIMENTS); + expect(store.pageInfoByPageId()).toEqual({ + 'page-1': { url: '/home', host: CURRENT_SITE_ID }, + 'page-2': { url: '/checkout', host: CURRENT_SITE_ID }, + 'page-3': { url: '/remote', host: OTHER_SITE_ID } + }); + expect(store.status()).toBe(ComponentStatus.LOADED); + }); + + it('should stay loading while the page lookup is in flight', () => { + contentSearchGet.mockReturnValue(NEVER); + + initStore(); + + expect(store.experiments()).toEqual(EXPERIMENTS); + expect(store.status()).toBe(ComponentStatus.LOADING); + }); + + it('should skip the page lookup and land loaded when the list is empty', () => { + getAllUnfiltered.mockReturnValue(of([])); + + initStore(); + + expect(contentSearchGet).not.toHaveBeenCalled(); + expect(store.status()).toBe(ComponentStatus.LOADED); + }); + }); + + describe('analytics health gate', () => { + it('should check the analytics health before requesting the list', () => { + const dispatchSpy = jest.spyOn(Dispatcher.prototype, 'dispatch'); + + initStore(); + + const dispatchedTypes = dispatchSpy.mock.calls.map(([event]) => event.type); + expect(healthCheck).toHaveBeenCalledTimes(1); + expect( + dispatchedTypes.indexOf(dotExperimentsListPageEvents.checkHealth.type) + ).toBeLessThan( + dispatchedTypes.indexOf(dotExperimentsListPageEvents.loadExperiments.type) + ); + + dispatchSpy.mockRestore(); + }); + + it('should load the list when analytics reports OK', () => { + initStore(); + + expect(store.healthStatus()).toBe(HealthStatusTypes.OK); + expect(store.isMisconfigured()).toBe(false); + expect(getAllUnfiltered).toHaveBeenCalledTimes(1); + expect(store.status()).toBe(ComponentStatus.LOADED); + }); + + it.each([HealthStatusTypes.NOT_CONFIGURED, HealthStatusTypes.CONFIGURATION_ERROR])( + 'should flag %s as misconfigured and never query the experiments', + (healthStatus) => { + healthCheck.mockReturnValue(of(healthStatus)); + + initStore(); + + expect(store.healthStatus()).toBe(healthStatus); + expect(store.isMisconfigured()).toBe(true); + expect(getAllUnfiltered).not.toHaveBeenCalled(); + expect(contentSearchGet).not.toHaveBeenCalled(); + expect(store.status()).toBe(ComponentStatus.LOADED); + } + ); + + it('should not claim a misconfiguration while the health check is in flight', () => { + healthCheck.mockReturnValue(NEVER); + + initStore(); + + expect(store.healthStatus()).toBeNull(); + expect(store.isMisconfigured()).toBe(false); + expect(store.status()).toBe(ComponentStatus.LOADING); + expect(getAllUnfiltered).not.toHaveBeenCalled(); + }); + + it('should end in error and report the failure when the health check fails', () => { + const error = httpError(500); + healthCheck.mockReturnValue(throwError(() => error)); + + initStore(); + + expect(store.status()).toBe(ComponentStatus.ERROR); + expect(store.error()).toBe(error); + expect(httpErrorManager.handle).toHaveBeenCalledWith(error); + expect(getAllUnfiltered).not.toHaveBeenCalled(); + }); + }); + + describe('load failure', () => { + it('should end in error and report the failure when the list request fails', () => { + const error = httpError(500); + getAllUnfiltered.mockReturnValue(throwError(() => error)); + + initStore(); + + expect(store.status()).toBe(ComponentStatus.ERROR); + expect(store.experiments()).toEqual([]); + expect(store.error()).toBe(error); + expect(httpErrorManager.handle).toHaveBeenCalledWith(error); + }); + + it('should end in error when the page lookup fails, since no experiment can be scoped', () => { + const error = httpError(403); + contentSearchGet.mockReturnValue(throwError(() => error)); + + initStore(); + + expect(store.status()).toBe(ComponentStatus.ERROR); + expect(store.pageInfoByPageId()).toEqual({}); + expect(httpErrorManager.handle).toHaveBeenCalledWith(error); + }); + }); + + describe('CRUD actions', () => { + interface CrudCase { + action: string; + requested: EventCreator; + serviceCall: jest.Mock; + } + + const CRUD_CASES: CrudCase[] = [ + { + action: 'archive', + requested: dotExperimentsListPageEvents.archiveExperiment, + serviceCall: archive + }, + { + action: 'delete', + requested: dotExperimentsListPageEvents.deleteExperiment, + serviceCall: remove + }, + { + action: 'end', + requested: dotExperimentsListPageEvents.endExperiment, + serviceCall: stop + }, + // `abort` deliberately cancels the schedule, mirroring the legacy per-page store. + { + action: 'abort', + requested: dotExperimentsListPageEvents.abortExperiment, + serviceCall: cancelSchedule + }, + { + action: 'cancelSchedule', + requested: dotExperimentsListPageEvents.cancelScheduleExperiment, + serviceCall: cancelSchedule + } + ]; + + describe.each(CRUD_CASES)('$action', ({ requested, serviceCall }) => { + beforeEach(() => initStore()); + + it('should call the service with the experiment id and reload the list on success', () => { + dispatcher.dispatch(requested(EXPERIMENT_DRAFT)); + + expect(serviceCall).toHaveBeenCalledWith(EXPERIMENT_DRAFT.id); + expect(getAllUnfiltered).toHaveBeenCalledTimes(2); + expect(store.status()).toBe(ComponentStatus.LOADED); + }); + + it('should report the failure and keep the list usable on error', () => { + const error = httpError(400); + serviceCall.mockReturnValue(throwError(() => error)); + + dispatcher.dispatch(requested(EXPERIMENT_DRAFT)); + + expect(httpErrorManager.handle).toHaveBeenCalledWith(error); + expect(store.status()).toBe(ComponentStatus.LOADED); + expect(getAllUnfiltered).toHaveBeenCalledTimes(1); + }); + }); + }); + + describe('site scoping', () => { + beforeEach(() => initStore()); + + it('should keep only the experiments whose page resolves to the current site', () => { + expect(store.siteScopedExperiments()).toEqual([ + EXPERIMENT_DRAFT, + EXPERIMENT_RUNNING, + EXPERIMENT_ARCHIVED + ]); + }); + + it('should drop an experiment whose page id could not be resolved', () => { + expect(store.siteScopedExperiments()).not.toContain(EXPERIMENT_ORPHAN); + }); + + it('should drop an experiment whose page belongs to another site', () => { + expect(store.siteScopedExperiments()).not.toContain(EXPERIMENT_OTHER_SITE); + }); + + it('should show nothing while there is no current site', () => { + currentSiteId.set(null); + + expect(store.siteScopedExperiments()).toEqual([]); + }); + }); + + describe('search', () => { + beforeEach(() => initStore()); + + it('should match the experiment name case-insensitively', () => { + dispatcher.dispatch(dotExperimentsListPageEvents.filterChanged('ALPHA')); + + expect(store.searchedExperiments()).toEqual([EXPERIMENT_DRAFT]); + }); + + it('should match the resolved page path', () => { + dispatcher.dispatch(dotExperimentsListPageEvents.filterChanged('/CheckOut')); + + expect(store.searchedExperiments()).toEqual([EXPERIMENT_RUNNING]); + }); + + it('should match the description', () => { + dispatcher.dispatch(dotExperimentsListPageEvents.filterChanged('HEADLINE')); + + expect(store.searchedExperiments()).toEqual([EXPERIMENT_RUNNING]); + }); + + it('should match on description even when the name does not contain the term', () => { + // 'funnel' appears only in the description, never in the name or the page path, so + // this fails outright if description is not one of the searched fields. + dispatcher.dispatch(dotExperimentsListPageEvents.filterChanged('funnel')); + + expect(store.searchedExperiments()).toEqual([EXPERIMENT_DRAFT]); + }); + + it('should tolerate an experiment with no description', () => { + // EXPERIMENT_ARCHIVED carries none; a term that matches its name must still work. + dispatcher.dispatch( + dotExperimentsListPageEvents.statusesChanged([DotExperimentStatus.ARCHIVED]) + ); + dispatcher.dispatch(dotExperimentsListPageEvents.filterChanged('Delta')); + + expect(store.searchedExperiments()).toEqual([EXPERIMENT_ARCHIVED]); + }); + + it('should return nothing when neither name, description nor page path match', () => { + dispatcher.dispatch(dotExperimentsListPageEvents.filterChanged('no-match')); + + expect(store.searchedExperiments()).toEqual([]); + }); + + it('should never match an experiment outside the current site', () => { + dispatcher.dispatch(dotExperimentsListPageEvents.filterChanged('gamma')); + + expect(store.searchedExperiments()).toEqual([]); + }); + }); + + describe('statusCounts', () => { + beforeEach(() => initStore()); + + it('should count the site scoped experiments per status', () => { + expect(store.statusCounts()).toEqual({ + [DotExperimentStatus.DRAFT]: 1, + [DotExperimentStatus.RUNNING]: 1, + [DotExperimentStatus.ARCHIVED]: 1, + [DotExperimentStatus.SCHEDULED]: 0, + [DotExperimentStatus.ENDED]: 0 + }); + }); + + it('should not change when the status selection changes', () => { + const countsBefore = store.statusCounts(); + + dispatcher.dispatch( + dotExperimentsListPageEvents.statusesChanged([DotExperimentStatus.DRAFT]) + ); + + expect(store.statusCounts()).toEqual(countsBefore); + expect(store.filteredExperiments()).toEqual([EXPERIMENT_DRAFT]); + }); + + it('should follow the search term', () => { + dispatcher.dispatch(dotExperimentsListPageEvents.filterChanged('delta')); + + expect(store.statusCounts()).toEqual({ + [DotExperimentStatus.DRAFT]: 0, + [DotExperimentStatus.RUNNING]: 0, + [DotExperimentStatus.ARCHIVED]: 1, + [DotExperimentStatus.SCHEDULED]: 0, + [DotExperimentStatus.ENDED]: 0 + }); + }); + }); + + describe('status selection', () => { + beforeEach(() => initStore()); + + it('should start with nothing selected', () => { + // The filter opens unticked like every other filter in the admin, so the chip + // reads as unfiltered rather than permanently highlighted. + expect(store.selectedStatuses()).toEqual([]); + expect(DEFAULT_EXPERIMENTS_LIST_STATUSES).toEqual([]); + }); + + it('should hide archived experiments until they are explicitly selected', () => { + expect(store.filteredExperiments()).not.toContain(EXPERIMENT_ARCHIVED); + + dispatcher.dispatch( + dotExperimentsListPageEvents.statusesChanged([DotExperimentStatus.ARCHIVED]) + ); + + expect(store.filteredExperiments()).toEqual([EXPERIMENT_ARCHIVED]); + }); + + it('should show every active status when the selection is cleared', () => { + // An empty selection means "no status filter", not "match nothing" — clearing the + // chip widens the list back out rather than emptying the table. Archived is the + // one exception: it stays opt-in. + dispatcher.dispatch( + dotExperimentsListPageEvents.statusesChanged([DotExperimentStatus.DRAFT]) + ); + dispatcher.dispatch(dotExperimentsListPageEvents.statusesChanged([])); + + const expected = store + .searchedExperiments() + .filter(({ status }) => status !== DotExperimentStatus.ARCHIVED); + + expect(store.filteredExperiments()).toEqual(expected); + expect(store.filteredExperiments().length).toBeGreaterThan(0); + expect(store.filteredExperiments()).not.toContain(EXPERIMENT_ARCHIVED); + }); + + it('should still honour the search when the status selection is cleared', () => { + dispatcher.dispatch(dotExperimentsListPageEvents.statusesChanged([])); + dispatcher.dispatch(dotExperimentsListPageEvents.filterChanged(EXPERIMENT_DRAFT.name)); + + expect(store.filteredExperiments()).toEqual([EXPERIMENT_DRAFT]); + }); + + it('should reset paging when the status selection changes', () => { + dispatcher.dispatch(dotExperimentsListPageEvents.pageChanged({ page: 3, perPage: 10 })); + dispatcher.dispatch( + dotExperimentsListPageEvents.statusesChanged([DotExperimentStatus.DRAFT]) + ); + + expect(store.page()).toBe(DEFAULT_EXPERIMENTS_LIST_PAGE); + }); + }); + + describe('sorting', () => { + beforeEach(() => initStore()); + + it('should sort by modDate DESC by default', () => { + expect(store.sortedExperiments()).toEqual([EXPERIMENT_DRAFT, EXPERIMENT_RUNNING]); + }); + + it('should sort by modDate ASC when the direction flips', () => { + dispatcher.dispatch( + dotExperimentsListPageEvents.sortChanged({ orderBy: 'modDate', direction: 'ASC' }) + ); + + expect(store.sortedExperiments()).toEqual([EXPERIMENT_RUNNING, EXPERIMENT_DRAFT]); + }); + + it('should sort by name, which is now a sortable column', () => { + dispatcher.dispatch( + dotExperimentsListPageEvents.sortChanged({ orderBy: 'name', direction: 'DESC' }) + ); + + // 'Beta rollout' before 'Alpha campaign' — proves the column is wired through, and + // not just coincidentally in modDate order. + expect(store.sortedExperiments()).toEqual([EXPERIMENT_RUNNING, EXPERIMENT_DRAFT]); + }); + + it('should sort by the resolved page path', () => { + // /checkout before /home, which is the opposite of the default modDate order. + dispatcher.dispatch( + dotExperimentsListPageEvents.sortChanged({ orderBy: 'page', direction: 'ASC' }) + ); + + expect(store.sortedExperiments()).toEqual([EXPERIMENT_RUNNING, EXPERIMENT_DRAFT]); + }); + + it('should keep the API order for an unrecognised column', () => { + // Reachable by hand-editing `?orderby=`, so it must not throw. + dispatcher.dispatch( + dotExperimentsListPageEvents.sortChanged({ + orderBy: 'not-a-column', + direction: 'ASC' + }) + ); + + expect(store.sortedExperiments()).toEqual([EXPERIMENT_DRAFT, EXPERIMENT_RUNNING]); + }); + }); + + describe('paging', () => { + beforeEach(() => initStore()); + + it('should return the slice of the requested page', () => { + dispatcher.dispatch(dotExperimentsListPageEvents.pageChanged({ page: 1, perPage: 1 })); + expect(store.pagedExperiments()).toEqual([EXPERIMENT_DRAFT]); + + dispatcher.dispatch(dotExperimentsListPageEvents.pageChanged({ page: 2, perPage: 1 })); + expect(store.pagedExperiments()).toEqual([EXPERIMENT_RUNNING]); + }); + + it('should count every filtered experiment, not just the current page', () => { + dispatcher.dispatch(dotExperimentsListPageEvents.pageChanged({ page: 1, perPage: 1 })); + + expect(store.totalRecords()).toBe(2); + }); + }); + + describe('URL hydration', () => { + it('should hydrate filter, paging and sort from the query params', () => { + queryParams = { + page: '3', + per_page: '10', + orderby: 'name', + direction: 'asc', + filter: 'beta' + }; + + initStore(); + + expect(store.page()).toBe(3); + expect(store.perPage()).toBe(10); + expect(store.orderBy()).toBe('name'); + expect(store.direction()).toBe('ASC'); + expect(store.filter()).toBe('beta'); + }); + + it('should hydrate a single status param provided as a string', () => { + queryParams = { status: 'draft' }; + + initStore(); + + expect(store.selectedStatuses()).toEqual([DotExperimentStatus.DRAFT]); + }); + + it('should hydrate a repeated status param provided as an array', () => { + queryParams = { status: ['draft', 'RUNNING', 'not-a-status'] }; + + initStore(); + + expect(store.selectedStatuses()).toEqual([ + DotExperimentStatus.DRAFT, + DotExperimentStatus.RUNNING + ]); + }); + + it('should fall back to the default selection when the status param is absent', () => { + queryParams = { filter: 'beta' }; + + initStore(); + + expect(store.selectedStatuses()).toEqual(DEFAULT_EXPERIMENTS_LIST_STATUSES); + }); + + it('should ignore unusable paging params', () => { + queryParams = { page: '0', per_page: 'many' }; + + initStore(); + + expect(store.page()).toBe(DEFAULT_EXPERIMENTS_LIST_PAGE); + expect(store.perPage()).toBe(DEFAULT_EXPERIMENTS_LIST_PER_PAGE); + }); + + it('should hydrate before the first fetch is requested', () => { + queryParams = { page: '3' }; + const dispatchSpy = jest.spyOn(Dispatcher.prototype, 'dispatch'); + + initStore(); + + const dispatchedTypes = dispatchSpy.mock.calls.map(([event]) => event.type); + expect(dispatchedTypes.indexOf(dotExperimentsListPageEvents.hydratedFromUrl.type)).toBe( + 0 + ); + expect(dispatchedTypes.indexOf(dotExperimentsListPageEvents.checkHealth.type)).toBe(1); + expect( + dispatchedTypes.indexOf(dotExperimentsListPageEvents.loadExperiments.type) + ).toBeGreaterThan(1); + + dispatchSpy.mockRestore(); + }); + }); + + describe('site switch', () => { + beforeEach(() => initStore()); + + it('should keep the view state, restart paging and reload the list', () => { + dispatcher.dispatch(dotExperimentsListPageEvents.filterChanged('alpha')); + dispatcher.dispatch( + dotExperimentsListPageEvents.statusesChanged([DotExperimentStatus.DRAFT]) + ); + dispatcher.dispatch( + dotExperimentsListPageEvents.sortChanged({ orderBy: 'modDate', direction: 'ASC' }) + ); + dispatcher.dispatch(dotExperimentsListPageEvents.pageChanged({ page: 3, perPage: 10 })); + + currentSiteId.set(OTHER_SITE_ID); + spectator.flushEffects(); + + expect(store.page()).toBe(DEFAULT_EXPERIMENTS_LIST_PAGE); + expect(store.perPage()).toBe(10); + expect(store.filter()).toBe('alpha'); + expect(store.orderBy()).toBe('modDate'); + expect(store.direction()).toBe('ASC'); + expect(store.selectedStatuses()).toEqual([DotExperimentStatus.DRAFT]); + expect(getAllUnfiltered).toHaveBeenCalledTimes(2); + }); + + it('should not reload when the site signal emits the same site', () => { + currentSiteId.set(CURRENT_SITE_ID); + spectator.flushEffects(); + + expect(getAllUnfiltered).toHaveBeenCalledTimes(1); + }); + }); + + describe('site switch while analytics is misconfigured', () => { + it('should not request the list', () => { + healthCheck.mockReturnValue(of(HealthStatusTypes.NOT_CONFIGURED)); + initStore(); + + expect(getAllUnfiltered).not.toHaveBeenCalled(); + + currentSiteId.set(OTHER_SITE_ID); + spectator.flushEffects(); + + // The gate applies to every load, not just the first one — otherwise switching + // site would query experiments behind the misconfiguration screen. + expect(getAllUnfiltered).not.toHaveBeenCalled(); + expect(store.isMisconfigured()).toBe(true); + }); + }); + + describe('url write-back', () => { + const lastWrittenUrl = (): string => locationGo.mock.calls.at(-1)?.[0] as string; + + const writtenParams = (): URLSearchParams => + new URLSearchParams(lastWrittenUrl().split('?')[1] ?? ''); + + it('should write no query params while every value is its default', () => { + initStore(); + + expect(lastWrittenUrl()).not.toContain('?'); + }); + + it('should write only the params that differ from their defaults', () => { + initStore(); + + // Paging last: changing the filter or the sort resets the page to the first one. + dispatcher.dispatch(dotExperimentsListPageEvents.filterChanged('summer')); + dispatcher.dispatch( + dotExperimentsListPageEvents.sortChanged({ + orderBy: DEFAULT_EXPERIMENTS_LIST_ORDER_BY, + direction: 'ASC' + }) + ); + dispatcher.dispatch(dotExperimentsListPageEvents.pageChanged({ page: 2, perPage: 10 })); + spectator.flushEffects(); + + const params = writtenParams(); + + expect(params.get('page')).toBe('2'); + expect(params.get('per_page')).toBe('10'); + expect(params.get('filter')).toBe('summer'); + expect(params.get('direction')).toBe('ASC'); + // Left at its default, so it is absent rather than restated. + expect(params.get('orderby')).toBeNull(); + }); + + it('should repeat the status param once per selected status', () => { + initStore(); + + dispatcher.dispatch( + dotExperimentsListPageEvents.statusesChanged([ + DotExperimentStatus.DRAFT, + DotExperimentStatus.ARCHIVED + ]) + ); + spectator.flushEffects(); + + expect(writtenParams().getAll('status')).toEqual([ + DotExperimentStatus.DRAFT, + DotExperimentStatus.ARCHIVED + ]); + }); + + it('should drop the status param when the selection returns to the default', () => { + initStore(); + + dispatcher.dispatch( + dotExperimentsListPageEvents.statusesChanged([DotExperimentStatus.DRAFT]) + ); + spectator.flushEffects(); + dispatcher.dispatch( + dotExperimentsListPageEvents.statusesChanged(DEFAULT_EXPERIMENTS_LIST_STATUSES) + ); + spectator.flushEffects(); + + expect(writtenParams().getAll('status')).toEqual([]); + }); + + it('should not rewrite the URL when it already matches the view state', () => { + // The URL the effect is about to compute for a pristine view state. + locationPath.mockReturnValue('/'); + + initStore(); + + expect(locationGo).not.toHaveBeenCalled(); + }); + }); + + describe('goal filter', () => { + it('should count the goals of the site scoped experiments', () => { + initStore(); + + // EXPERIMENT_OTHER_SITE and EXPERIMENT_ORPHAN never reach the list, so they cannot + // contribute; EXPERIMENT_ARCHIVED has no goal at all. + expect(store.goalCounts()[GOAL_TYPES.BOUNCE_RATE]).toBe(1); + expect(store.goalCounts()[GOAL_TYPES.EXIT_RATE]).toBe(1); + expect(store.goalCounts()[GOAL_TYPES.REACH_PAGE]).toBe(0); + }); + + it('should show every experiment while no goal is selected', () => { + initStore(); + + expect(store.selectedGoals()).toEqual(DEFAULT_EXPERIMENTS_LIST_GOALS); + expect(store.filteredExperiments().map(({ id }) => id)).toEqual( + store.statusFilteredExperiments().map(({ id }) => id) + ); + }); + + it('should keep only the experiments carrying the selected goal', () => { + initStore(); + + dispatcher.dispatch( + dotExperimentsListPageEvents.goalsChanged([GOAL_TYPES.BOUNCE_RATE]) + ); + + expect(store.filteredExperiments().map(({ id }) => id)).toEqual(['exp-draft']); + }); + + it('should drop experiments with no goal once any goal is selected', () => { + initStore(); + + // The archived one has no goal, so it matches nothing — this also pins that a + // goal filter does not resurrect it. + dispatcher.dispatch( + dotExperimentsListPageEvents.statusesChanged([DotExperimentStatus.ARCHIVED]) + ); + dispatcher.dispatch( + dotExperimentsListPageEvents.goalsChanged([GOAL_TYPES.BOUNCE_RATE]) + ); + + expect(store.filteredExperiments()).toEqual([]); + }); + + it('should narrow together with the status filter, not instead of it', () => { + initStore(); + + dispatcher.dispatch( + dotExperimentsListPageEvents.statusesChanged([DotExperimentStatus.RUNNING]) + ); + dispatcher.dispatch( + dotExperimentsListPageEvents.goalsChanged([GOAL_TYPES.BOUNCE_RATE]) + ); + + // Draft matches the goal but not the status; Running matches the status but not the + // goal. An AND leaves nothing. + expect(store.filteredExperiments()).toEqual([]); + }); + + it('should return to the first page when the goal selection changes', () => { + initStore(); + + dispatcher.dispatch(dotExperimentsListPageEvents.pageChanged({ page: 3, perPage: 10 })); + dispatcher.dispatch(dotExperimentsListPageEvents.goalsChanged([GOAL_TYPES.EXIT_RATE])); + + expect(store.page()).toBe(DEFAULT_EXPERIMENTS_LIST_PAGE); + }); + + it('should hydrate the goal selection from the url', () => { + queryParams = { goal: 'exit_rate' }; + + initStore(); + + expect(store.selectedGoals()).toEqual([GOAL_TYPES.EXIT_RATE]); + }); + + it('should drop an unknown goal from the url', () => { + queryParams = { goal: ['EXIT_RATE', 'NOT_A_GOAL'] }; + + initStore(); + + expect(store.selectedGoals()).toEqual([GOAL_TYPES.EXIT_RATE]); + }); + + it('should write the selected goals to the url and drop the param when cleared', () => { + initStore(); + + dispatcher.dispatch( + dotExperimentsListPageEvents.goalsChanged([ + GOAL_TYPES.BOUNCE_RATE, + GOAL_TYPES.EXIT_RATE + ]) + ); + spectator.flushEffects(); + + const written = new URLSearchParams( + (locationGo.mock.calls.at(-1)?.[0] as string).split('?')[1] ?? '' + ); + expect(written.getAll('goal')).toEqual([GOAL_TYPES.BOUNCE_RATE, GOAL_TYPES.EXIT_RATE]); + + dispatcher.dispatch(dotExperimentsListPageEvents.goalsChanged([])); + spectator.flushEffects(); + + const cleared = new URLSearchParams( + (locationGo.mock.calls.at(-1)?.[0] as string).split('?')[1] ?? '' + ); + expect(cleared.getAll('goal')).toEqual([]); + }); + }); + + describe('search over the rendered page path', () => { + it('should find a row by the pageId the Page column falls back to', () => { + // A page that resolves but carries no url: `resolvePagePath` renders the raw id in + // the column, so searching that id has to match or the row is unfindable. + getAllUnfiltered.mockReturnValue( + of([buildExperiment({ id: 'exp-x', pageId: 'page-9' })]) + ); + contentSearchGet.mockReturnValue( + of({ + jsonObjectView: { + contentlets: [buildPageContentlet('page-9', '', CURRENT_SITE_ID)] + } + }) + ); + initStore(); + + dispatcher.dispatch(dotExperimentsListPageEvents.filterChanged('page-9')); + + expect(store.searchedExperiments().map(({ id }) => id)).toEqual(['exp-x']); + }); + }); +}); diff --git a/core-web/libs/portlets/dot-experiments/portlet/src/lib/store/dot-experiments-list.store.ts b/core-web/libs/portlets/dot-experiments/portlet/src/lib/store/dot-experiments-list.store.ts new file mode 100644 index 000000000000..4cf9422ae840 --- /dev/null +++ b/core-web/libs/portlets/dot-experiments/portlet/src/lib/store/dot-experiments-list.store.ts @@ -0,0 +1,620 @@ +import { mapResponse } from '@ngrx/operators'; +import { signalStore, withComputed, withHooks, withState } from '@ngrx/signals'; +import { Dispatcher, Events, on, withEventHandlers, withReducer } from '@ngrx/signals/events'; +import { of, SubscriptionLike } from 'rxjs'; + +import { Location } from '@angular/common'; +import { HttpErrorResponse } from '@angular/common/http'; +import { computed, effect, EffectRef, inject, untracked } from '@angular/core'; +import { ActivatedRoute, Router } from '@angular/router'; + +import { filter, map, mergeMap, switchMap } from 'rxjs/operators'; + +import { + DotContentSearchService, + DotExperimentsService, + DotHttpErrorManagerService +} from '@dotcms/data-access'; +import { + ComponentStatus, + DotCMSContentlet, + DotExperiment, + DotExperimentStatus, + GOAL_TYPES, + HealthStatusTypes +} from '@dotcms/dotcms-models'; +import { GlobalStore } from '@dotcms/store'; + +import { dotExperimentsApiEvents } from './dot-experiments-api.events'; +import { dotExperimentsListPageEvents } from './dot-experiments-list-page.events'; + +import { + DEFAULT_EXPERIMENTS_LIST_DIRECTION, + DEFAULT_EXPERIMENTS_LIST_GOALS, + DEFAULT_EXPERIMENTS_LIST_ORDER_BY, + DEFAULT_EXPERIMENTS_LIST_PAGE, + DEFAULT_EXPERIMENTS_LIST_PER_PAGE, + DEFAULT_EXPERIMENTS_LIST_STATUSES, + OPT_IN_STATUSES, + PAGE_LOOKUP_LANGUAGE_HEADROOM +} from '../shared/constants'; +import { DotExperimentPageInfo, DotExperimentsListViewState } from '../shared/models'; +import { + comparatorFor, + distinctPageIds, + emptyGoalCounts, + emptyStatusCounts, + fromRouteParams, + goalTypeOfExperiment, + parseViewState, + resolvedPageInfo, + toQueryParams +} from '../util/dot-experiments-list-store.util'; +import { resolvePagePath } from '../util/dot-experiments-list.util'; + +/** Full state of the experiments list. */ +export interface DotExperimentsListState extends DotExperimentsListViewState { + status: ComponentStatus; + /** Analytics health, `null` until the gate resolves. Anything but `OK` blocks the list. */ + healthStatus: HealthStatusTypes | null; + /** Every experiment returned by the API, across all sites. Narrowed by `siteScopedExperiments`. */ + experiments: DotExperiment[]; + /** Page url/host resolved per `pageId`; the only source of site information for an experiment. */ + pageInfoByPageId: Record; + error: unknown; +} + +const initialState: DotExperimentsListState = { + status: ComponentStatus.LOADING, + healthStatus: null, + experiments: [], + pageInfoByPageId: {}, + filter: '', + selectedStatuses: DEFAULT_EXPERIMENTS_LIST_STATUSES, + selectedGoals: DEFAULT_EXPERIMENTS_LIST_GOALS, + page: DEFAULT_EXPERIMENTS_LIST_PAGE, + perPage: DEFAULT_EXPERIMENTS_LIST_PER_PAGE, + orderBy: DEFAULT_EXPERIMENTS_LIST_ORDER_BY, + direction: DEFAULT_EXPERIMENTS_LIST_DIRECTION, + error: null +}; + +/** Shape of the `/api/content/_search` entity the page lookup reads contentlets from. */ +interface ContentSearchEntity { + jsonObjectView: { contentlets: DotCMSContentlet[] }; +} + +/** + * Store for the experiments list. + * + * Nothing is fetched until the Analytics health gate passes: `isMisconfigured` is the inline + * equivalent of the legacy `AnalyticsAppGuard` redirect, so `/experiments` stays the URL. + * + * The API returns every experiment regardless of site and `DotExperiment` has no host, so the + * list is narrowed client-side: one bulk `htmlpageasset` lookup resolves each distinct `pageId` + * to its `url` (Page column) and `host` (site filter). Search, status narrowing, sorting and + * paging are then derived from that set, all in computed signals. + * + * State only ever changes through dispatched events (`withReducer`); the store exposes no + * mutating methods and never opens UI — confirmations and toasts belong to the component. + * + * Not provided in root: supply it in the route (or component) `providers` together with + * `DotExperimentsService`, so each list instance is isolated. + */ +export const DotExperimentsListStore = signalStore( + withState(initialState), + withComputed((store) => { + const globalStore = inject(GlobalStore); + + /** + * Fails closed: an experiment whose `pageId` could not be resolved is dropped, so an + * experiment from another site can never leak into the list. + */ + const siteScopedExperiments = computed(() => { + const currentSiteId = globalStore.currentSiteId(); + + if (!currentSiteId) { + return []; + } + + const pageInfoByPageId = store.pageInfoByPageId(); + + return store + .experiments() + .filter( + (experiment) => pageInfoByPageId[experiment.pageId]?.host === currentSiteId + ); + }); + + const searchedExperiments = computed(() => { + const term = store.filter().trim().toLowerCase(); + + if (!term) { + return siteScopedExperiments(); + } + + const pageInfoByPageId = store.pageInfoByPageId(); + + return siteScopedExperiments().filter((experiment) => { + // `resolvePagePath`, not the raw url, so this searches exactly what the Page + // column renders — including the pageId it falls back to when a page has no url. + // Otherwise a row showing an id could not be found by typing that id. + const pagePath = resolvePagePath(experiment.pageId, pageInfoByPageId); + + // Every field the row actually shows as text: an experiment the user can read + // on screen should be findable by anything they can read on it. + return ( + experiment.name.toLowerCase().includes(term) || + (experiment.description ?? '').toLowerCase().includes(term) || + pagePath.toLowerCase().includes(term) + ); + }); + }); + + /** + * Counts per status over the site + search filtered set, deliberately independent of + * `selectedStatuses` so selecting a status never changes the numbers shown in the chips. + */ + const statusCounts = computed>(() => { + const counts = emptyStatusCounts(); + + for (const experiment of searchedExperiments()) { + counts[experiment.status] = (counts[experiment.status] ?? 0) + 1; + } + + return counts; + }); + + /** + * Counts per goal, over the same set as `statusCounts` and independent of both + * selections for the same reason: picking a value must not move the numbers next to + * the values you have not picked yet. + */ + const goalCounts = computed>(() => { + const counts = emptyGoalCounts(); + + for (const experiment of searchedExperiments()) { + const goal = goalTypeOfExperiment(experiment); + + if (goal) { + counts[goal] = (counts[goal] ?? 0) + 1; + } + } + + return counts; + }); + + const statusFilteredExperiments = computed(() => { + const selectedStatuses = store.selectedStatuses(); + + // An empty selection is "no status filter", not "match nothing" — clearing the chip + // widens the list back out the way clearing any other filter does, rather than + // leaving an empty table whose only escape is re-picking every status. + // Archived stays out of that default view; it is opt-in. + if (!selectedStatuses.length) { + return searchedExperiments().filter( + (experiment) => !OPT_IN_STATUSES.includes(experiment.status) + ); + } + + return searchedExperiments().filter((experiment) => + selectedStatuses.includes(experiment.status) + ); + }); + + /** + * The two chips narrow together: an experiment has to satisfy both. An experiment with + * no goal at all therefore drops out as soon as any goal is picked, since it matches + * none of them. + */ + const filteredExperiments = computed(() => { + const selectedGoals = store.selectedGoals(); + + if (!selectedGoals.length) { + return statusFilteredExperiments(); + } + + return statusFilteredExperiments().filter((experiment) => { + const goal = goalTypeOfExperiment(experiment); + + return goal !== null && selectedGoals.includes(goal); + }); + }); + + const sortedExperiments = computed(() => { + const experiments = filteredExperiments(); + const compare = comparatorFor(store.orderBy(), store.pageInfoByPageId()); + + // An unrecognised `orderby` keeps the API order rather than throwing. + if (!compare) { + return experiments; + } + + const factor = store.direction() === 'ASC' ? 1 : -1; + + return [...experiments].sort((a, b) => compare(a, b) * factor); + }); + + const pagedExperiments = computed(() => { + const perPage = store.perPage(); + const start = (store.page() - 1) * perPage; + + return sortedExperiments().slice(start, start + perPage); + }); + + return { + siteScopedExperiments, + searchedExperiments, + statusCounts, + goalCounts, + statusFilteredExperiments, + filteredExperiments, + sortedExperiments, + pagedExperiments, + totalRecords: computed(() => filteredExperiments().length), + /** + * Same rule as the legacy `AnalyticsAppGuard`: only `OK` passes. Stays `false` + * while the gate is pending, so the list is never blocked on a guess. + */ + isMisconfigured: computed(() => { + const healthStatus = store.healthStatus(); + + return healthStatus !== null && healthStatus !== HealthStatusTypes.OK; + }) + }; + }), + withReducer( + on(dotExperimentsApiEvents.healthCheckSucceeded, ({ payload }) => ({ + healthStatus: payload, + // A non-OK gate stops the flow here: nothing else is fetched, so settle instead of + // leaving the table stuck on its skeleton. + status: + payload === HealthStatusTypes.OK ? ComponentStatus.LOADING : ComponentStatus.LOADED + })), + on(dotExperimentsApiEvents.healthCheckFailed, ({ payload }) => ({ + status: ComponentStatus.ERROR, + error: payload + })), + on(dotExperimentsListPageEvents.loadExperiments, () => ({ + status: ComponentStatus.LOADING, + error: null + })), + on(dotExperimentsApiEvents.listSucceeded, ({ payload }) => ({ + experiments: payload, + pageInfoByPageId: {}, + // Stay in `loading` until the page lookup resolves: without it the site filter fails + // closed and the table would flash an empty "loaded" list first. + status: payload.length > 0 ? ComponentStatus.LOADING : ComponentStatus.LOADED, + error: null + })), + on(dotExperimentsApiEvents.listFailed, ({ payload }) => ({ + status: ComponentStatus.ERROR, + experiments: [], + pageInfoByPageId: {}, + error: payload + })), + on(dotExperimentsApiEvents.pageInfoSucceeded, ({ payload }) => ({ + pageInfoByPageId: payload, + status: ComponentStatus.LOADED + })), + // Without page info no experiment can be attributed to a site, so this is a failed load + // rather than an empty list. + on(dotExperimentsApiEvents.pageInfoFailed, ({ payload }) => ({ + status: ComponentStatus.ERROR, + pageInfoByPageId: {}, + error: payload + })), + on(dotExperimentsListPageEvents.filterChanged, ({ payload }) => ({ + filter: payload, + page: DEFAULT_EXPERIMENTS_LIST_PAGE + })), + on(dotExperimentsListPageEvents.statusesChanged, ({ payload }) => ({ + selectedStatuses: payload, + page: DEFAULT_EXPERIMENTS_LIST_PAGE + })), + on(dotExperimentsListPageEvents.goalsChanged, ({ payload }) => ({ + selectedGoals: payload, + page: DEFAULT_EXPERIMENTS_LIST_PAGE + })), + on(dotExperimentsListPageEvents.pageChanged, ({ payload }) => ({ + page: payload.page, + perPage: payload.perPage + })), + on(dotExperimentsListPageEvents.sortChanged, ({ payload }) => ({ + orderBy: payload.orderBy, + direction: payload.direction, + page: DEFAULT_EXPERIMENTS_LIST_PAGE + })), + on(dotExperimentsListPageEvents.hydratedFromUrl, ({ payload }) => ({ ...payload })), + // A site switch keeps search, sort and status selection but always restarts paging. + on(dotExperimentsListPageEvents.siteChanged, () => ({ + page: DEFAULT_EXPERIMENTS_LIST_PAGE, + status: ComponentStatus.LOADING + })), + on( + dotExperimentsListPageEvents.archiveExperiment, + dotExperimentsListPageEvents.deleteExperiment, + dotExperimentsListPageEvents.endExperiment, + dotExperimentsListPageEvents.abortExperiment, + dotExperimentsListPageEvents.cancelScheduleExperiment, + () => ({ status: ComponentStatus.LOADING }) + ), + // A failed action leaves the list usable instead of blanking it with an error screen. + on( + dotExperimentsApiEvents.archiveFailed, + dotExperimentsApiEvents.deleteFailed, + dotExperimentsApiEvents.endFailed, + dotExperimentsApiEvents.abortFailed, + dotExperimentsApiEvents.cancelScheduleFailed, + () => ({ status: ComponentStatus.LOADED }) + ) + ), + withEventHandlers( + ( + store, + events = inject(Events), + experimentsService = inject(DotExperimentsService), + contentSearchService = inject(DotContentSearchService), + httpErrorManager = inject(DotHttpErrorManagerService) + ) => { + /** + * Turns a failed row action into its `Failed` event, after routing the error through + * the shared manager. Only the toast differs between actions, so this is the one + * piece of the CRUD flows worth naming. + */ + const toFailure = + (failed: (error: HttpErrorResponse) => T) => + (error: HttpErrorResponse): T => { + httpErrorManager.handle(error); + + return failed(error); + }; + + return { + healthCheck$: events.on(dotExperimentsListPageEvents.checkHealth).pipe( + switchMap(() => + experimentsService.healthCheck().pipe( + mapResponse({ + next: (healthStatus) => + dotExperimentsApiEvents.healthCheckSucceeded(healthStatus), + error: toFailure(dotExperimentsApiEvents.healthCheckFailed) + }) + ) + ) + ), + + // Querying experiments on a broken Analytics install is pointless, so the first + // load hangs off the gate rather than off init. + loadAfterHealthCheck$: events.on(dotExperimentsApiEvents.healthCheckSucceeded).pipe( + filter(({ payload }) => payload === HealthStatusTypes.OK), + map(() => dotExperimentsListPageEvents.loadExperiments()) + ), + + loadList$: events.on(dotExperimentsListPageEvents.loadExperiments).pipe( + switchMap(() => + experimentsService.getAllUnfiltered().pipe( + mapResponse({ + next: (experiments) => + dotExperimentsApiEvents.listSucceeded(experiments), + error: toFailure(dotExperimentsApiEvents.listFailed) + }) + ) + ) + ), + + resolvePageInfo$: events.on(dotExperimentsApiEvents.listSucceeded).pipe( + switchMap(({ payload }) => { + const pageIds = distinctPageIds(payload); + + // Nothing to resolve, but the status still has to leave `loading`: + // `listSucceeded` set it there for any non-empty payload, and no other + // event would follow. Returning EMPTY left the skeleton spinning forever. + if (pageIds.length === 0) { + return of(dotExperimentsApiEvents.pageInfoSucceeded({})); + } + + return contentSearchService + .get({ + query: `+contentType:htmlpageasset +working:true +identifier:(${pageIds.join(' ')})`, + limit: pageIds.length * PAGE_LOOKUP_LANGUAGE_HEADROOM + }) + .pipe( + mapResponse({ + next: (entity) => + dotExperimentsApiEvents.pageInfoSucceeded( + resolvedPageInfo(entity, pageIds) + ), + error: toFailure(dotExperimentsApiEvents.pageInfoFailed) + }) + ); + }) + ), + + // Each row action is written out rather than generated from a table, so the + // service call behind an action is readable where the action is declared. + // `mergeMap`, not `switchMap`: acting on a second row must not cancel the first. + // The confirmation is already accepted in the component by the time these run. + archive$: events.on(dotExperimentsListPageEvents.archiveExperiment).pipe( + mergeMap(({ payload }) => + experimentsService.archive(payload.id).pipe( + mapResponse({ + next: () => dotExperimentsApiEvents.archiveSucceeded(payload), + error: toFailure(dotExperimentsApiEvents.archiveFailed) + }) + ) + ) + ), + + delete$: events.on(dotExperimentsListPageEvents.deleteExperiment).pipe( + mergeMap(({ payload }) => + experimentsService.delete(payload.id).pipe( + mapResponse({ + next: () => dotExperimentsApiEvents.deleteSucceeded(payload), + error: toFailure(dotExperimentsApiEvents.deleteFailed) + }) + ) + ) + ), + + end$: events.on(dotExperimentsListPageEvents.endExperiment).pipe( + mergeMap(({ payload }) => + experimentsService.stop(payload.id).pipe( + mapResponse({ + next: () => dotExperimentsApiEvents.endSucceeded(payload), + error: toFailure(dotExperimentsApiEvents.endFailed) + }) + ) + ) + ), + + // There is no dedicated abort endpoint; aborting a running experiment cancels it, + // same as the legacy per-page list store does. + abort$: events.on(dotExperimentsListPageEvents.abortExperiment).pipe( + mergeMap(({ payload }) => + experimentsService.cancelSchedule(payload.id).pipe( + mapResponse({ + next: () => dotExperimentsApiEvents.abortSucceeded(payload), + error: toFailure(dotExperimentsApiEvents.abortFailed) + }) + ) + ) + ), + + cancelSchedule$: events + .on(dotExperimentsListPageEvents.cancelScheduleExperiment) + .pipe( + mergeMap(({ payload }) => + experimentsService.cancelSchedule(payload.id).pipe( + mapResponse({ + next: () => + dotExperimentsApiEvents.cancelScheduleSucceeded(payload), + error: toFailure(dotExperimentsApiEvents.cancelScheduleFailed) + }) + ) + ) + ), + + // Every row action mutates the server-side list, so all five reload through the + // same path instead of each repeating the request. + reloadAfterAction$: events + .on( + dotExperimentsApiEvents.archiveSucceeded, + dotExperimentsApiEvents.deleteSucceeded, + dotExperimentsApiEvents.endSucceeded, + dotExperimentsApiEvents.abortSucceeded, + dotExperimentsApiEvents.cancelScheduleSucceeded + ) + .pipe(map(() => dotExperimentsListPageEvents.loadExperiments())) + }; + } + ), + withHooks((store) => { + const route = inject(ActivatedRoute); + const router = inject(Router); + const location = inject(Location); + const globalStore = inject(GlobalStore); + const dispatcher = inject(Dispatcher); + + let siteEffect: EffectRef; + let syncUrlEffect: EffectRef; + let locationSubscription: SubscriptionLike; + + /** + * `Location.go` rather than `Router.navigate`: the view state is derived client-side, so + * a filter change must not re-run the route. The guard keeps a no-op write (most notably + * the one this effect triggers on its own hydration) from pushing a duplicate history + * entry. + */ + const writeUrl = (queryParams: Record): void => { + const newUrl = router + .createUrlTree([], { queryParams, queryParamsHandling: 'merge' }) + .toString(); + + if (newUrl !== location.path(true)) { + location.go(newUrl); + } + }; + + return { + onInit() { + // Hydrate before the first fetch so the initial render already honours the URL. + dispatcher.dispatch( + dotExperimentsListPageEvents.hydratedFromUrl( + parseViewState(fromRouteParams(route.snapshot.queryParams)) + ) + ); + // The health gate owns the first fetch: the list is only requested once + // Analytics reports `OK`. + dispatcher.dispatch(dotExperimentsListPageEvents.checkHealth()); + + /** + * Back/Forward re-hydration. `writeUrl` above uses `Location.go` (which does not + * notify), so only a real popstate reaches here — re-read the restored URL and fold it back in. No reload is + * needed: paging, sorting and filtering are all derived client-side. + */ + locationSubscription = location.subscribe((event) => { + const params = new URLSearchParams(event.url?.split('?')[1] ?? ''); + + dispatcher.dispatch( + dotExperimentsListPageEvents.hydratedFromUrl(parseViewState(params)) + ); + }); + + /** + * Mirrors the view state back into the URL, so the list is shareable and + * survives a reload. Lives here rather than in the component because the store + * already owns the other half of this contract — it parses the URL on entry and + * on popstate — and splitting read from write invites the two to drift. + */ + syncUrlEffect = effect(() => { + const queryParams = toQueryParams({ + filter: store.filter(), + selectedStatuses: store.selectedStatuses(), + selectedGoals: store.selectedGoals(), + page: store.page(), + perPage: store.perPage(), + orderBy: store.orderBy(), + direction: store.direction() + }); + + untracked(() => writeUrl(queryParams)); + }); + + // Site is resolved asynchronously, so seed with whatever is known at init and only + // react to actual switches. + let knownSiteId = untracked(() => globalStore.currentSiteId()); + + siteEffect = effect(() => { + const currentSiteId = globalStore.currentSiteId(); + + if (currentSiteId === knownSiteId) { + return; + } + + knownSiteId = currentSiteId; + + untracked(() => { + dispatcher.dispatch( + dotExperimentsListPageEvents.siteChanged(currentSiteId) + ); + + // Same rule as the initial load: never query experiments on an install + // whose Analytics app is not configured. Without this the switch would + // fire a request behind the misconfiguration screen. + if (store.healthStatus() === HealthStatusTypes.OK) { + dispatcher.dispatch(dotExperimentsListPageEvents.loadExperiments()); + } + }); + }); + }, + onDestroy() { + siteEffect?.destroy(); + syncUrlEffect?.destroy(); + locationSubscription?.unsubscribe(); + } + }; + }) +); + +/** Injectable type of {@link DotExperimentsListStore}, for typing component/service fields. */ +export type DotExperimentsListStore = InstanceType; diff --git a/core-web/libs/portlets/dot-experiments/portlet/src/lib/util/dot-experiments-list-store.util.spec.ts b/core-web/libs/portlets/dot-experiments/portlet/src/lib/util/dot-experiments-list-store.util.spec.ts new file mode 100644 index 000000000000..6866d00890b7 --- /dev/null +++ b/core-web/libs/portlets/dot-experiments/portlet/src/lib/util/dot-experiments-list-store.util.spec.ts @@ -0,0 +1,143 @@ +import { DotExperiment, DotExperimentStatus, GOAL_TYPES } from '@dotcms/dotcms-models'; + +import { comparatorFor } from './dot-experiments-list-store.util'; + +import { DotExperimentPageInfo } from '../shared/models'; + +const experiment = (partial: Partial): DotExperiment => + ({ id: 'id', pageId: 'page-1', name: 'Experiment', ...partial }) as DotExperiment; + +const PAGE_INFO: Record = { + 'page-a': { url: '/about', host: 'host-1' }, + 'page-z': { url: '/zoo', host: 'host-1' } +}; + +/** Sorts with the comparator under test, returning the field that identifies each row. */ +const sortedBy = ( + field: string, + experiments: DotExperiment[], + pick: (experiment: DotExperiment) => unknown = ({ id }) => id +) => { + const compare = comparatorFor(field, PAGE_INFO); + + return [...experiments].sort(compare ?? undefined).map(pick); +}; + +describe('comparatorFor', () => { + it('should return null for an unrecognised field', () => { + expect(comparatorFor('not-a-column', PAGE_INFO)).toBeNull(); + }); + + describe('name', () => { + it('should sort alphabetically', () => { + const experiments = [ + experiment({ id: 'b', name: 'Beta' }), + experiment({ id: 'a', name: 'Alpha' }) + ]; + + expect(sortedBy('name', experiments)).toEqual(['a', 'b']); + }); + + it('should ignore case, so mixed casing does not split the alphabet', () => { + const experiments = [ + experiment({ id: 'upper', name: 'Zebra' }), + experiment({ id: 'lower', name: 'apple' }) + ]; + + // A codepoint sort would put 'Zebra' first, since uppercase sorts before lowercase. + expect(sortedBy('name', experiments)).toEqual(['lower', 'upper']); + }); + }); + + describe('page', () => { + it('should sort by the resolved path, not the pageId', () => { + const experiments = [ + experiment({ id: 'zoo', pageId: 'page-z' }), + experiment({ id: 'about', pageId: 'page-a' }) + ]; + + expect(sortedBy('page', experiments)).toEqual(['about', 'zoo']); + }); + + it('should treat an unresolved page as empty rather than dropping it', () => { + const experiments = [ + experiment({ id: 'about', pageId: 'page-a' }), + experiment({ id: 'orphan', pageId: 'page-missing' }) + ]; + + expect(sortedBy('page', experiments)).toEqual(['orphan', 'about']); + }); + }); + + describe('goal', () => { + const withGoal = (id: string, type?: GOAL_TYPES) => + experiment({ + id, + goals: type + ? ({ primary: { type, conditions: [] } } as unknown as DotExperiment['goals']) + : null + }); + + it('should sort by goal type', () => { + const experiments = [ + withGoal('exit', GOAL_TYPES.EXIT_RATE), + withGoal('bounce', GOAL_TYPES.BOUNCE_RATE) + ]; + + expect(sortedBy('goal', experiments)).toEqual(['bounce', 'exit']); + }); + + it('should push experiments with no goal to the end', () => { + const experiments = [withGoal('none'), withGoal('bounce', GOAL_TYPES.BOUNCE_RATE)]; + + expect(sortedBy('goal', experiments)).toEqual(['bounce', 'none']); + }); + }); + + describe('schedule', () => { + const withStart = (id: string, startDate: number | null) => + experiment({ + id, + scheduling: { startDate, endDate: null } as DotExperiment['scheduling'] + }); + + it('should sort by start date', () => { + const experiments = [withStart('later', 2000), withStart('earlier', 1000)]; + + expect(sortedBy('schedule', experiments)).toEqual(['earlier', 'later']); + }); + + it('should push unscheduled experiments after every scheduled one', () => { + const experiments = [ + experiment({ id: 'unscheduled', scheduling: null }), + withStart('scheduled', 1000) + ]; + + expect(sortedBy('schedule', experiments)).toEqual(['scheduled', 'unscheduled']); + }); + }); + + describe('status', () => { + it('should sort by lifecycle order, not alphabetically', () => { + const experiments = [ + experiment({ id: 'running', status: DotExperimentStatus.RUNNING }), + experiment({ id: 'draft', status: DotExperimentStatus.DRAFT }), + experiment({ id: 'archived', status: DotExperimentStatus.ARCHIVED }) + ]; + + // Alphabetically this would be archived, draft, running. + expect(sortedBy('status', experiments)).toEqual(['draft', 'running', 'archived']); + }); + }); + + describe('modDate', () => { + it('should sort numerically', () => { + const experiments = [ + experiment({ id: 'new', modDate: 300 }), + experiment({ id: 'old', modDate: 100 }) + ]; + + expect(sortedBy('modDate', experiments)).toEqual(['old', 'new']); + }); + }); +}); diff --git a/core-web/libs/portlets/dot-experiments/portlet/src/lib/util/dot-experiments-list-store.util.ts b/core-web/libs/portlets/dot-experiments/portlet/src/lib/util/dot-experiments-list-store.util.ts new file mode 100644 index 000000000000..d52d0a07e5ee --- /dev/null +++ b/core-web/libs/portlets/dot-experiments/portlet/src/lib/util/dot-experiments-list-store.util.ts @@ -0,0 +1,285 @@ +import { Params } from '@angular/router'; + +import { + DotCMSContentlet, + DotExperiment, + DotExperimentStatus, + ExperimentsStatusList, + GOAL_TYPES +} from '@dotcms/dotcms-models'; + +import { goalTypeOf } from './dot-experiments-list.util'; + +import { + DEFAULT_EXPERIMENTS_LIST_DIRECTION, + EXPERIMENTS_LIST_SORT_FIELDS, + DEFAULT_EXPERIMENTS_LIST_GOALS, + DEFAULT_EXPERIMENTS_LIST_ORDER_BY, + DEFAULT_EXPERIMENTS_LIST_PAGE, + DEFAULT_EXPERIMENTS_LIST_PER_PAGE, + DEFAULT_EXPERIMENTS_LIST_STATUSES +} from '../shared/constants'; +import { DotExperimentPageInfo, DotExperimentsListViewState } from '../shared/models'; + +/** + * Pure helpers behind the experiments list store: URL parsing on the way in, response shaping + * on the way out. Kept out of the store so each can be read — and tested — on its own, without + * standing up the store, its injected services or its lifecycle hooks. + */ + +/** Reads query params from either an `ActivatedRoute` snapshot or a parsed popstate URL. */ +export interface QueryParamReader { + get(key: string): string | null; + getAll(key: string): string[]; +} + +export function fromRouteParams(params: Params): QueryParamReader { + const values = (key: string): string[] => { + const value: unknown = params[key]; + + if (value == null) { + return []; + } + + return Array.isArray(value) ? value.map(String) : [String(value)]; + }; + + return { + get: (key) => values(key)[0] ?? null, + getAll: values + }; +} + +export function parseViewState(reader: QueryParamReader): DotExperimentsListViewState { + return { + filter: reader.get('filter') ?? '', + selectedStatuses: parseStatuses(reader.getAll('status')), + selectedGoals: parseGoals(reader.getAll('goal')), + page: parsePositiveInteger(reader.get('page'), DEFAULT_EXPERIMENTS_LIST_PAGE), + perPage: parsePositiveInteger(reader.get('per_page'), DEFAULT_EXPERIMENTS_LIST_PER_PAGE), + orderBy: reader.get('orderby') || DEFAULT_EXPERIMENTS_LIST_ORDER_BY, + direction: reader.get('direction')?.toUpperCase() === 'ASC' ? 'ASC' : 'DESC' + }; +} + +/** + * An absent `status` param means "the default selection"; a present but unusable one (e.g. + * `?status=`) means the user deselected everything, which is not the same thing. + */ +export function parseStatuses(rawStatuses: string[]): DotExperimentStatus[] { + if (rawStatuses.length === 0) { + return DEFAULT_EXPERIMENTS_LIST_STATUSES; + } + + const allStatuses = Object.values(DotExperimentStatus); + + return rawStatuses + .map((rawStatus) => rawStatus.toUpperCase() as DotExperimentStatus) + .filter((status) => allStatuses.includes(status)); +} + +/** Same rule as {@link parseStatuses}: unknown values are dropped rather than trusted. */ +export function parseGoals(rawGoals: string[]): GOAL_TYPES[] { + if (rawGoals.length === 0) { + return DEFAULT_EXPERIMENTS_LIST_GOALS; + } + + const allGoals = Object.values(GOAL_TYPES); + + return rawGoals + .map((rawGoal) => rawGoal.toUpperCase() as GOAL_TYPES) + .filter((goal) => allGoals.includes(goal)); +} + +export function parsePositiveInteger(rawValue: string | null, fallback: number): number { + const parsed = Number.parseInt(rawValue ?? '', 10); + + return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback; +} + +export function distinctPageIds(experiments: DotExperiment[]): string[] { + return [...new Set(experiments.map(({ pageId }) => pageId).filter(Boolean))]; +} + +export function toPageInfoByPageId( + contentlets: DotCMSContentlet[] +): Record { + return contentlets.reduce>((pageInfo, contentlet) => { + if (contentlet.identifier) { + pageInfo[contentlet.identifier] = { + url: contentlet.url ?? '', + host: contentlet.host ?? '' + }; + } + + return pageInfo; + }, {}); +} + +/** + * An experiment's goal type, or `null` when it has none. `goals` is keyed by level and the list + * only ever shows the primary one, which is the same one the Goal column renders. + */ +export function goalTypeOfExperiment(experiment: DotExperiment): GOAL_TYPES | null { + return goalTypeOf(experiment.goals); +} + +export function emptyGoalCounts(): Record { + return Object.values(GOAL_TYPES).reduce( + (counts, goal) => { + counts[goal] = 0; + + return counts; + }, + {} as Record + ); +} + +export function emptyStatusCounts(): Record { + return Object.values(DotExperimentStatus).reduce( + (counts, status) => { + counts[status] = 0; + + return counts; + }, + {} as Record + ); +} + +/** + * The inverse of {@link parseViewState}: the view state as query params. + * + * A value equal to its default is written as `null`, which removes the param — so a pristine + * list has no query string at all rather than a URL restating every default. + */ +export function toQueryParams( + view: DotExperimentsListViewState +): Record { + return { + page: nullWhenDefault(view.page, DEFAULT_EXPERIMENTS_LIST_PAGE), + per_page: nullWhenDefault(view.perPage, DEFAULT_EXPERIMENTS_LIST_PER_PAGE), + orderby: nullWhenDefault(view.orderBy, DEFAULT_EXPERIMENTS_LIST_ORDER_BY), + direction: nullWhenDefault(view.direction, DEFAULT_EXPERIMENTS_LIST_DIRECTION), + filter: view.filter || null, + status: isDefaultStatusSelection(view.selectedStatuses) ? null : view.selectedStatuses, + goal: + view.selectedGoals.length === DEFAULT_EXPERIMENTS_LIST_GOALS.length + ? null + : view.selectedGoals + }; +} + +function nullWhenDefault(value: T, defaultValue: T): string | null { + return value === defaultValue ? null : String(value); +} + +/** Order-insensitive set comparison: a reordered default selection is still the default. */ +function isDefaultStatusSelection(statuses: DotExperimentStatus[]): boolean { + if (statuses.length !== DEFAULT_EXPERIMENTS_LIST_STATUSES.length) { + return false; + } + + const selected = new Set(statuses); + + return DEFAULT_EXPERIMENTS_LIST_STATUSES.every((status) => selected.has(status)); +} + +/** Comparator applied to a pair of experiments, before the direction factor. */ +type ExperimentComparator = (a: DotExperiment, b: DotExperiment) => number; + +/** + * Lifecycle order, not alphabetical: sorting by status is only useful if Draft, Scheduled, + * Running, Ended and Archived come out in the order an experiment actually moves through them. + * Taken from `ExperimentsStatusList`, which is the same order the filter lists them in. + */ +const STATUS_ORDER = new Map( + ExperimentsStatusList.map(({ value }, index) => [value, index]) +); + +/** Case-insensitive, locale-aware, so `alpha` and `Alpha` sort together. */ +function compareText(a: string, b: string): number { + return a.localeCompare(b, undefined, { sensitivity: 'base' }); +} + +/** + * The comparator for a sortable column, or `null` for anything unrecognised — an unknown + * `orderby` (a hand-edited URL) then leaves the API order untouched rather than throwing. + * + * Missing values sort as empty or as `Infinity`, which puts unscheduled experiments and those + * with no goal at the end while ascending. + */ +export function comparatorFor( + field: string, + pageInfoByPageId: Record +): ExperimentComparator | null { + switch (field) { + case EXPERIMENTS_LIST_SORT_FIELDS.NAME: + return (a, b) => compareText(a.name, b.name); + + case EXPERIMENTS_LIST_SORT_FIELDS.PAGE: + return (a, b) => + compareText( + pageInfoByPageId[a.pageId]?.url ?? '', + pageInfoByPageId[b.pageId]?.url ?? '' + ); + + case EXPERIMENTS_LIST_SORT_FIELDS.GOAL: + return (a, b) => + compareText( + goalTypeOfExperiment(a) ?? '\uffff', + goalTypeOfExperiment(b) ?? '\uffff' + ); + + case EXPERIMENTS_LIST_SORT_FIELDS.SCHEDULE: + return (a, b) => startTimeOf(a) - startTimeOf(b); + + case EXPERIMENTS_LIST_SORT_FIELDS.STATUS: + return (a, b) => + (STATUS_ORDER.get(a.status) ?? Number.MAX_SAFE_INTEGER) - + (STATUS_ORDER.get(b.status) ?? Number.MAX_SAFE_INTEGER); + + case EXPERIMENTS_LIST_SORT_FIELDS.MOD_DATE: + return (a, b) => a.modDate - b.modDate; + + default: + return null; + } +} + +/** Unscheduled experiments have no start date, so they sort after every scheduled one. */ +function startTimeOf(experiment: DotExperiment): number { + // Already an epoch, so it compares directly. + return experiment.scheduling?.startDate ?? Number.POSITIVE_INFINITY; +} + +/** Shape of the `/api/content/_search` entity the page lookup reads contentlets from. */ +interface PageLookupEntity { + jsonObjectView?: { contentlets?: DotCMSContentlet[] }; +} + +/** + * Page info for a lookup response, and a warning when the response did not cover every page + * asked for. + * + * An unresolved page is dropped by the site filter, which fails closed — so a short response + * shortens the list with no error anywhere and a total that agrees with it. That is + * indistinguishable from reality on screen, so the shortfall is at least made diagnosable here. + */ +export function resolvedPageInfo( + entity: PageLookupEntity | null | undefined, + requestedPageIds: string[] +): Record { + const pageInfo = toPageInfoByPageId(entity?.jsonObjectView?.contentlets ?? []); + const missing = requestedPageIds.filter((pageId) => !pageInfo[pageId]); + + if (missing.length) { + console.warn( + `[experiments] page lookup resolved ${requestedPageIds.length - missing.length} of ${ + requestedPageIds.length + } pages. Experiments on the rest are hidden from the list.`, + missing + ); + } + + return pageInfo; +} diff --git a/core-web/libs/portlets/dot-experiments/portlet/src/lib/util/dot-experiments-list.util.spec.ts b/core-web/libs/portlets/dot-experiments/portlet/src/lib/util/dot-experiments-list.util.spec.ts new file mode 100644 index 000000000000..972065c3120b --- /dev/null +++ b/core-web/libs/portlets/dot-experiments/portlet/src/lib/util/dot-experiments-list.util.spec.ts @@ -0,0 +1,143 @@ +import { + GOAL_OPERATORS, + GOAL_PARAMETERS, + GOAL_TYPES, + Goals, + TrafficProportion, + TrafficProportionTypes +} from '@dotcms/dotcms-models'; + +import { + ExperimentScheduleLabels, + formatSchedule, + goalTypeOf, + resolvePagePath, + variantsCount +} from './dot-experiments-list.util'; + +import { DotExperimentPageInfo } from '../shared/models'; + +const LABELS: ExperimentScheduleLabels = { + open: 'Until stopped', + none: 'Not scheduled' +}; + +/** Local noon keeps the formatted day stable regardless of the runner timezone. */ +const atLocalNoon = (year: number, monthIndex: number, day: number): number => + new Date(year, monthIndex, day, 12).getTime(); + +const JUN_25_2026 = atLocalNoon(2026, 5, 25); +const JUL_9_2026 = atLocalNoon(2026, 6, 9); + +describe('dot-experiments-list.util', () => { + describe('formatSchedule', () => { + it('should render both dates joined by an arrow', () => { + expect( + formatSchedule({ startDate: JUN_25_2026, endDate: JUL_9_2026 }, LABELS, 'en-US') + ).toBe('Jun 25, 2026 → Jul 9, 2026'); + }); + + it('should render the open label when only the start date is set', () => { + expect(formatSchedule({ startDate: JUN_25_2026, endDate: null }, LABELS, 'en-US')).toBe( + `Jun 25, 2026 → ${LABELS.open}` + ); + }); + + it('should render the none label when the scheduling is null', () => { + expect(formatSchedule(null, LABELS, 'en-US')).toBe(LABELS.none); + }); + + it('should render the none label when the scheduling is undefined', () => { + expect(formatSchedule(undefined, LABELS, 'en-US')).toBe(LABELS.none); + }); + + it('should render the none label when both dates are null', () => { + expect(formatSchedule({ startDate: null, endDate: null }, LABELS, 'en-US')).toBe( + LABELS.none + ); + }); + + it('should render the none label when only the end date is set', () => { + expect(formatSchedule({ startDate: null, endDate: JUL_9_2026 }, LABELS, 'en-US')).toBe( + LABELS.none + ); + }); + + it('should treat a NaN start date as absent', () => { + expect(formatSchedule({ startDate: NaN, endDate: JUL_9_2026 }, LABELS, 'en-US')).toBe( + LABELS.none + ); + }); + + it('should treat a non-finite end date as absent', () => { + expect( + formatSchedule({ startDate: JUN_25_2026, endDate: Infinity }, LABELS, 'en-US') + ).toBe(`Jun 25, 2026 → ${LABELS.open}`); + }); + }); + + describe('goalTypeOf', () => { + it('should return the type of the primary goal', () => { + const goals: Goals = { + primary: { + name: 'default', + type: GOAL_TYPES.BOUNCE_RATE, + conditions: [ + { + parameter: GOAL_PARAMETERS.URL, + operator: GOAL_OPERATORS.EQUALS, + value: 'index' + } + ] + } + }; + + expect(goalTypeOf(goals)).toBe(GOAL_TYPES.BOUNCE_RATE); + }); + + it('should return null when there are no goals', () => { + expect(goalTypeOf(null)).toBeNull(); + expect(goalTypeOf(undefined)).toBeNull(); + expect(goalTypeOf({} as Goals)).toBeNull(); + }); + }); + + describe('variantsCount', () => { + it('should count the variants of the traffic proportion', () => { + const trafficProportion: TrafficProportion = { + type: TrafficProportionTypes.SPLIT_EVENLY, + variants: [ + { id: 'DEFAULT', name: 'Original', weight: 50 }, + { id: '111', name: 'Variant A', weight: 50 } + ] + }; + + expect(variantsCount(trafficProportion)).toBe(2); + }); + + it('should return 0 when the traffic proportion is missing', () => { + expect(variantsCount(null)).toBe(0); + expect(variantsCount(undefined)).toBe(0); + expect(variantsCount({} as TrafficProportion)).toBe(0); + }); + }); + + describe('resolvePagePath', () => { + const PAGE_INFO: Record = { + 'page-1': { url: '/blog/index', host: 'host-1' }, + 'page-empty-url': { url: '', host: 'host-1' } + }; + + it('should return the resolved url', () => { + expect(resolvePagePath('page-1', PAGE_INFO)).toBe('/blog/index'); + }); + + it('should fall back to the pageId when the page is not in the map', () => { + expect(resolvePagePath('missing-page', PAGE_INFO)).toBe('missing-page'); + }); + + it('should fall back to the pageId when the resolved url is empty', () => { + expect(resolvePagePath('page-empty-url', PAGE_INFO)).toBe('page-empty-url'); + }); + }); +}); diff --git a/core-web/libs/portlets/dot-experiments/portlet/src/lib/util/dot-experiments-list.util.ts b/core-web/libs/portlets/dot-experiments/portlet/src/lib/util/dot-experiments-list.util.ts new file mode 100644 index 000000000000..8dc143fddac3 --- /dev/null +++ b/core-web/libs/portlets/dot-experiments/portlet/src/lib/util/dot-experiments-list.util.ts @@ -0,0 +1,104 @@ +import { + AllowedActionsByExperimentStatus, + type GOAL_TYPES, + type Goals, + type DotExperimentStatus, + type RangeOfDateAndTime, + type TrafficProportion +} from '@dotcms/dotcms-models'; + +import { DotExperimentPageInfo, ExperimentListAction } from '../shared/models'; + +/** Day-level format shared by every schedule cell of the experiments list (e.g. `Jun 25, 2026`). */ +const SCHEDULE_DATE_FORMAT: Intl.DateTimeFormatOptions = { + month: 'short', + day: 'numeric', + year: 'numeric' +}; + +/** Literal arrow drawn between the start and the end of a scheduled range. */ +const SCHEDULE_RANGE_SEPARATOR = '→'; + +/** + * Translated labels the schedule formatter needs. They are passed in — instead of being + * hardcoded here — so the util stays free of user-facing English and the call site owns i18n. + */ +export interface ExperimentScheduleLabels { + /** Shown in place of the end date when the experiment runs until manually stopped. */ + open: string; + /** Shown when the experiment has no usable start date. */ + none: string; +} + +/** + * Formats an experiment schedule as a single display string. + * + * - both dates set → `Jun 25, 2026 → Jul 9, 2026` + * - start only → `Jun 25, 2026 → {labels.open}` + * - no usable start date (null scheduling, both dates null, or an end-only range) → `{labels.none}` + * + * @param scheduling - Epoch-millisecond range returned by the experiments API + * @param labels - Already translated fallback labels + * @param locale - BCP 47 tag; defaults to the runtime locale. Pass it explicitly for deterministic tests. + */ +export function formatSchedule( + scheduling: RangeOfDateAndTime | null | undefined, + labels: ExperimentScheduleLabels, + locale?: string +): string { + const start = toDisplayDate(scheduling?.startDate, locale); + + if (!start) { + return labels.none; + } + + const end = toDisplayDate(scheduling?.endDate, locale); + + return `${start} ${SCHEDULE_RANGE_SEPARATOR} ${end ?? labels.open}`; +} + +/** + * Returns the type of the experiment's primary goal, or `null` when no goal is configured. + * The empty-state placeholder is rendered by the template, not by this function. + */ +export function goalTypeOf(goals: Goals | null | undefined): GOAL_TYPES | null { + return goals?.primary?.type ?? null; +} + +/** Counts the variants of an experiment, tolerating a missing traffic proportion. */ +export function variantsCount(trafficProportion: TrafficProportion | null | undefined): number { + return trafficProportion?.variants?.length ?? 0; +} + +/** + * Resolves the readable page path for an experiment, falling back to the raw `pageId` + * when the page is missing from the map (deleted page, or metadata not loaded yet). + */ +export function resolvePagePath( + pageId: string, + pageInfoByPageId: Record +): string { + const url = pageInfoByPageId?.[pageId]?.url; + + return url ? url : pageId; +} + +/** Formats an epoch-millisecond timestamp, or returns `null` when it is absent or invalid. */ +function toDisplayDate(epochMillis: number | null | undefined, locale?: string): string | null { + if (epochMillis == null || !Number.isFinite(epochMillis)) { + return null; + } + + const date = new Date(epochMillis); + + if (Number.isNaN(date.getTime())) { + return null; + } + + return new Intl.DateTimeFormat(locale, SCHEDULE_DATE_FORMAT).format(date); +} + +/** Whether an action is offered for a status, per the shared `AllowedActionsByExperimentStatus`. */ +export function isAllowed(action: ExperimentListAction, status: DotExperimentStatus): boolean { + return AllowedActionsByExperimentStatus[action].includes(status); +} diff --git a/core-web/libs/portlets/dot-experiments/portlet/src/test-setup.ts b/core-web/libs/portlets/dot-experiments/portlet/src/test-setup.ts index f8c270c505a0..5c5e9f0585d7 100644 --- a/core-web/libs/portlets/dot-experiments/portlet/src/test-setup.ts +++ b/core-web/libs/portlets/dot-experiments/portlet/src/test-setup.ts @@ -5,6 +5,36 @@ setupZoneTestEnv({ errorOnUnknownProperties: true }); +// `@dotcms/dotcms-models` resolves the chart theme colors at MODULE IMPORT TIME: the +// `dotCMSThemeColors` const in `dot-experiments.model.ts` calls +// `getComputedStyle(document.body).getPropertyValue('--color-palette-black-op-*')`. +// Every spec that transitively imports the models library therefore evaluates it while +// jsdom has no stylesheet loaded, and jsdom can answer with `undefined`/`null` — or throw — +// for an unresolved CSS custom property. These shims must run before any spec import. +// They delegate to jsdom's real implementation so specs asserting on layout keep working, +// and only guarantee that `getPropertyValue()` always answers with a string. +const nativeGetComputedStyle = window.getComputedStyle.bind(window); +const nativeGetPropertyValue = CSSStyleDeclaration.prototype.getPropertyValue; +const emptyDeclaration = { + getPropertyValue: () => '' +} as unknown as CSSStyleDeclaration; + +CSSStyleDeclaration.prototype.getPropertyValue = function (property: string): string { + try { + return nativeGetPropertyValue.call(this, property) ?? ''; + } catch { + return ''; + } +}; + +window.getComputedStyle = (element: Element, pseudoElement?: string | null) => { + try { + return nativeGetComputedStyle(element, pseudoElement); + } catch { + return emptyDeclaration; + } +}; + // Workaround for the following issue: // https://github.com/jsdom/jsdom/issues/2177#issuecomment-1724971596 const originalConsoleError = console.error; diff --git a/core-web/libs/portlets/dot-publishing-queue/src/lib/components/dot-publishing-queue-status-filter/dot-publishing-queue-status-filter.component.html b/core-web/libs/portlets/dot-publishing-queue/src/lib/components/dot-publishing-queue-status-filter/dot-publishing-queue-status-filter.component.html index ece328a6044b..7e9592bdf15f 100644 --- a/core-web/libs/portlets/dot-publishing-queue/src/lib/components/dot-publishing-queue-status-filter/dot-publishing-queue-status-filter.component.html +++ b/core-web/libs/portlets/dot-publishing-queue/src/lib/components/dot-publishing-queue-status-filter/dot-publishing-queue-status-filter.component.html @@ -5,7 +5,7 @@ (removed)="onRemoveAll()" data-testid="pq-status-filter-chip" /> - + diff --git a/core-web/libs/portlets/dot-publishing-queue/src/lib/components/dot-publishing-queue-status-filter/dot-publishing-queue-status-filter.component.ts b/core-web/libs/portlets/dot-publishing-queue/src/lib/components/dot-publishing-queue-status-filter/dot-publishing-queue-status-filter.component.ts index 2fac1b0accb7..c8264d0cfc89 100644 --- a/core-web/libs/portlets/dot-publishing-queue/src/lib/components/dot-publishing-queue-status-filter/dot-publishing-queue-status-filter.component.ts +++ b/core-web/libs/portlets/dot-publishing-queue/src/lib/components/dot-publishing-queue-status-filter/dot-publishing-queue-status-filter.component.ts @@ -6,13 +6,7 @@ import { PopoverModule } from 'primeng/popover'; import { DotMessageService } from '@dotcms/data-access'; import { PublishAuditStatus } 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'; +import { DotChipFilterComponent, DotFilterListItemComponent, DotMessagePipe } from '@dotcms/ui'; import { DotPublishingQueueStore } from '../../store/dot-publishing-queue.store'; @@ -81,9 +75,6 @@ interface StatusOption { export class DotPublishingQueueStatusFilterComponent { readonly #store = inject(DotPublishingQueueStore); readonly #dotMessageService = inject(DotMessageService); - - protected readonly popoverPt = CHIP_FILTER_POPOVER_PT; - protected readonly listboxPt = CHIP_FILTER_LISTBOX_PT; protected readonly LISTBOX_SCROLL_HEIGHT = '320px'; /** Listbox options, deduplicated by translated label. Order follows diff --git a/core-web/libs/portlets/dot-users/src/lib/dot-users-list/components/dot-users-filter-by/dot-users-filter-by.component.html b/core-web/libs/portlets/dot-users/src/lib/dot-users-list/components/dot-users-filter-by/dot-users-filter-by.component.html index 4942a7df4b34..434ff449638d 100644 --- a/core-web/libs/portlets/dot-users/src/lib/dot-users-list/components/dot-users-filter-by/dot-users-filter-by.component.html +++ b/core-web/libs/portlets/dot-users/src/lib/dot-users-list/components/dot-users-filter-by/dot-users-filter-by.component.html @@ -5,14 +5,13 @@ (removed)="onRemove()" data-testid="users-filter-by-chip" /> - + diff --git a/core-web/libs/portlets/dot-users/src/lib/dot-users-list/components/dot-users-filter-by/dot-users-filter-by.component.ts b/core-web/libs/portlets/dot-users/src/lib/dot-users-list/components/dot-users-filter-by/dot-users-filter-by.component.ts index bd4090564457..6a81af15c416 100644 --- a/core-web/libs/portlets/dot-users/src/lib/dot-users-list/components/dot-users-filter-by/dot-users-filter-by.component.ts +++ b/core-web/libs/portlets/dot-users/src/lib/dot-users-list/components/dot-users-filter-by/dot-users-filter-by.component.ts @@ -5,13 +5,7 @@ import { ListboxModule } from 'primeng/listbox'; import { PopoverModule } from 'primeng/popover'; import { DotMessageService } from '@dotcms/data-access'; -import { - CHIP_FILTER_LISTBOX_PT, - CHIP_FILTER_POPOVER_PT, - DotChipFilterComponent, - DotFilterListItemComponent -} from '@dotcms/portlets/content-drive/ui'; -import { DotMessagePipe } from '@dotcms/ui'; +import { DotChipFilterComponent, DotFilterListItemComponent, DotMessagePipe } from '@dotcms/ui'; import { DotUsersListStore } from '../../store/dot-users-list.store'; @@ -46,9 +40,6 @@ export class DotUsersFilterByComponent { readonly #store = inject(DotUsersListStore); readonly #dotMessageService = inject(DotMessageService); - protected readonly popoverPt = CHIP_FILTER_POPOVER_PT; - protected readonly listboxPt = CHIP_FILTER_LISTBOX_PT; - protected readonly $options: FilterOption[] = [ { value: USERS_FILTER_ALL, diff --git a/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 b/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 index e7e249d65cbe..c0a3c4dd5b95 100644 --- a/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 +++ b/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 @@ -14,7 +14,12 @@ dotAvatar /> } - + + + + {{ title() }} @if (active()) { : {{ valuesLabel() }} + } @else if (emptyLabel()) { + : {{ emptyLabel() }} } @if (active()) {