Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions core-web/libs/dotcms-models/src/lib/dot-experiments.model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,14 @@ export interface DotExperimentVariantDetail {
probabilityToBeBest: string;
isWinner: boolean;
isPromoted: boolean;
/**
* Difference against the control's conversion rate, ready to render: signed percentage points
* with one decimal, or an em dash on the control row and when the control converted nothing.
*
* Optional because the backend does not send it and the legacy reports screen does not compute
* it — only the results screen does.
*/
liftVsOriginal?: string;
}

export interface Variant {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -203,11 +203,15 @@
{{ row.experiment.modDate | date: 'MMM d, y' }}
</td>
<td>
<!-- The design leads this cell with "View Results" / "Configure";
Configure is the first entry of the kebab and View Results lands
with the reports screen. -->
<div
class="flex items-center justify-end gap-1 opacity-0 transition-opacity duration-200 group-hover:opacity-100 focus-within:opacity-100">
<p-button
[text]="true"
[rounded]="true"
size="small"
[label]="'experiments.list.actions.view-results' | dm"
(onClick)="onViewResults(row.experiment)"
data-testid="experiment-view-results-btn" />
<p-button
[text]="true"
[rounded]="true"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -55,11 +55,11 @@ import {

import { DotExperimentListFilterComponent } from '../components/dot-experiment-list-filter/dot-experiment-list-filter.component';
import {
CONFIGURATION_SEGMENT,
EXPERIMENTS_URL,
GOAL_LABEL_KEYS,
NEW_EXPERIMENT_SEGMENT,
NO_GOAL_PLACEHOLDER,
RESULTS_SEGMENT,
ROWS_PER_PAGE_OPTIONS,
SEARCH_DEBOUNCE_MS,
SKELETON_COLUMNS,
Expand All @@ -77,6 +77,7 @@ 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 {
configureCommandsOf,
ExperimentScheduleLabels,
formatSchedule,
goalTypeOf,
Expand All @@ -88,11 +89,11 @@ import {
/** Where the New Experiment button goes: the Configure screen with nothing created yet. */
const NEW_EXPERIMENT_COMMANDS = [EXPERIMENTS_URL, NEW_EXPERIMENT_SEGMENT];

/** Configure URL of an experiment that already exists. */
const configureCommandsOf = (experimentId: string): string[] => [
/** Results URL of an experiment. */
const resultsCommandsOf = (experimentId: string): string[] => [
EXPERIMENTS_URL,
experimentId,
CONFIGURATION_SEGMENT
RESULTS_SEGMENT
];

@Component({
Expand Down Expand Up @@ -430,6 +431,17 @@ export class DotExperimentsListComponent {
this.#router.navigate(configureCommandsOf(experiment.id));
}

/**
* Opens the Results screen of an experiment.
*
* Ungated on purpose, unlike every kebab entry: `AllowedActionsByExperimentStatus.results`
* clears RUNNING and ENDED only, but the screen renders a waiting state of its own for an
* experiment with nothing to count yet, so the row leads with it whatever the status (AC6).
*/
onViewResults(experiment: DotExperiment): void {
this.#router.navigate(resultsCommandsOf(experiment.id));
}

confirmArchive(experiment: DotExperiment): void {
this.#confirm({
headerKey: 'experiments.action.archive',
Expand All @@ -444,8 +456,8 @@ export class DotExperimentsListComponent {

return [
{
// The primary action of the row: it leads the menu, and is the only entry every
// status allows.
// Leads the menu, behind the row's own View Results control: it is the only
// entry every status allows.
id: 'experiments-configure',
label: this.#dotMessageService.get('experiments.list.action.configure'),
visible: isAllowed('configuration', status),
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
<div class="flex w-full max-w-[1040px] flex-col gap-4">
<div class="flex flex-row gap-6 border-0 border-b border-solid border-gray-200" role="tablist">
@for (tab of tabs; track tab.id) {
<button
class="-mb-px cursor-pointer border-0 border-b-2 border-solid bg-transparent px-1 pb-3 text-base"
[class]="
$activeTab() === tab.id
? 'border-[#18186D] font-semibold text-[#18186D]'
: 'border-transparent font-medium text-[#64748b]'
"
[attr.aria-selected]="$activeTab() === tab.id"
[attr.data-testid]="'results-chart-tab-' + tab.id"
(click)="selectTab(tab.id)"
type="button"
role="tab">
{{ tab.labelKey | dm }}
</button>
}
</div>

@if ($activeTab() === 'daily') {
<dot-experiments-reports-chart
[isLoading]="store.$isLoading()"
[isEmpty]="$isDailyEmpty()"
[config]="dailyAxisLabels"
[data]="store.$dailyChartData()"
data-testid="results-daily-chart" />
} @else {
<dot-experiments-reports-chart
[isLoading]="store.$isLoading()"
[isEmpty]="$isBayesianEmpty()"
[config]="bayesianAxisLabels"
[data]="store.$bayesianChartData()"
[isLinearAxis]="true"
data-testid="results-bayesian-chart" />
}
</div>
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import { ChangeDetectionStrategy, Component, computed, inject, signal } from '@angular/core';

import { DotMessageService } from '@dotcms/data-access';
import { DotMessagePipe } from '@dotcms/ui';

import { DotExperimentsReportsChartComponent } from '../../../shared/ui/dot-experiments-reports-chart/dot-experiments-reports-chart.component';
import { DotExperimentsResultsStore } from '../../../store/dot-experiments-results.store';

/** The two reports the Results screen charts, one per tab. */
type ResultsChartTab = 'daily' | 'bayesian';

/** Axis labels the chart needs as plain strings, so they are resolved once per chart. */
interface ChartAxisLabels {
xAxisLabel: string;
yAxisLabel: string;
}

/**
* The charts half of the Results screen: Daily results and Bayesian results behind two tabs.
*
* Only the selected tab is rendered. The chart's legend is drawn by a Chart.js plugin that walks
* *up* from the canvas until it finds a `.legend-wrapper`, and that walk also inspects siblings —
* with both charts mounted at once, one could claim the other's wrapper and a legend would silently
* go missing. `@if` keeps exactly one canvas in the tree, so each chart can only ever find its own.
*
* For the same reason the chart component is composed as a plain child: nothing here wraps or
* re-projects its internals.
*
* Both charts are read-only views of the store — the Bayesian curves arrive already computed from
* the backend, and nothing is derived from them here.
*/
@Component({
selector: 'dot-experiments-results-charts',
imports: [DotExperimentsReportsChartComponent, DotMessagePipe],
templateUrl: './dot-experiments-results-charts.component.html',
changeDetection: ChangeDetectionStrategy.OnPush,
host: {
class: 'block w-full'
}
})
export class DotExperimentsResultsChartsComponent {
readonly #dotMessageService = inject(DotMessageService);

protected readonly store = inject(DotExperimentsResultsStore);

protected readonly $activeTab = signal<ResultsChartTab>('daily');

protected readonly tabs: readonly { id: ResultsChartTab; labelKey: string }[] = [
{ id: 'daily', labelKey: 'experiments.reports.chart.title' },
{ id: 'bayesian', labelKey: 'experiments.bayesian.reports.chart.title' }
];

protected readonly dailyAxisLabels: ChartAxisLabels = {
xAxisLabel: this.#dotMessageService.get('experiments.chart.xAxisLabel'),
yAxisLabel: this.#dotMessageService.get('experiments.chart.yAxisLabel')
};

protected readonly bayesianAxisLabels: ChartAxisLabels = {
xAxisLabel: this.#dotMessageService.get('experiments.chart.xAxisLabel.bayesian'),
yAxisLabel: this.#dotMessageService.get('experiments.chart.yAxisLabel.bayesian')
};

/**
* A chart with too few sessions, or with no payload at all — DRAFT and SCHEDULED included,
* where no results are ever fetched — hands the empty state to the chart component rather than
* drawing an axis nothing sits on.
*/
protected readonly $isDailyEmpty = computed<boolean>(
() => !this.store.$hasEnoughSessionsForDailyChart() || !this.store.$dailyChartData()
);

protected readonly $isBayesianEmpty = computed<boolean>(
() => !this.store.$hasEnoughDataForBayesianChart() || !this.store.$bayesianChartData()
);

protected selectTab(tab: ResultsChartTab): void {
this.$activeTab.set(tab);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
<div class="flex min-w-0 items-center gap-4">
<p-button
[rounded]="true"
[outlined]="true"
severity="secondary"
size="small"
styleClass="w-9 h-9 min-w-9 p-0"
(onClick)="onBackToList()"
[attr.aria-label]="'experiments.configure.action.back-to-list' | dm"
[pTooltip]="'experiments.configure.action.back-to-list' | dm"
tooltipPosition="bottom"
data-testid="experiments-results-back-btn">
<span class="material-symbols-rounded text-xl! leading-none" aria-hidden="true">
arrow_back
</span>
</p-button>

<div class="flex min-w-0 flex-col gap-0.5">
<div class="flex items-center gap-2.5">
<h1
class="m-0 truncate text-xl font-semibold text-surface-900"
[title]="$title()"
data-testid="experiments-results-title">
{{ $title() }}
</h1>
<p-tag
[severity]="$statusSeverity()"
[value]="$statusLabelKey() | dm"
data-testid="experiments-results-status-tag" />
</div>
<span
class="truncate text-sm text-surface-500"
[title]="$subline()"
data-testid="experiments-results-subline">
{{ $subline() }}
</span>
</div>
</div>

<div class="flex flex-none items-center gap-2">
<p-button
[rounded]="true"
[outlined]="true"
severity="secondary"
[label]="'experiments.action.configuration' | dm"
(onClick)="onConfiguration()"
data-testid="experiments-results-configuration-btn">
<span class="material-symbols-rounded text-lg! leading-none" aria-hidden="true">
settings
</span>
</p-button>

@if ($showStop()) {
<p-button
[rounded]="true"
[outlined]="true"
severity="danger"
[label]="'experiments.action.stop-experiment' | dm"
[disabled]="store.$isSaving()"
(onClick)="confirmStop()"
data-testid="experiments-results-stop-btn">
<span class="material-symbols-rounded text-lg! leading-none" aria-hidden="true">
stop_circle
</span>
</p-button>
}
</div>
Loading
Loading