diff --git a/backend/packages/Upgrade/src/api/models/Query.ts b/backend/packages/Upgrade/src/api/models/Query.ts index ef639b1975..da84367b51 100644 --- a/backend/packages/Upgrade/src/api/models/Query.ts +++ b/backend/packages/Upgrade/src/api/models/Query.ts @@ -3,7 +3,7 @@ import { BaseModel } from './base/BaseModel'; import { Metric } from './Metric'; import { Experiment } from './Experiment'; import { IsDefined } from 'class-validator'; -import { REPEATED_MEASURE } from 'upgrade_types'; +import { ExperimentQueryPayload, REPEATED_MEASURE } from 'upgrade_types'; import { ArchivedStats } from './ArchivedStats'; import { Type } from 'class-transformer'; @@ -17,7 +17,7 @@ export class Query extends BaseModel { public name: string; @Column('jsonb') - public query: any; + public query: ExperimentQueryPayload; @ManyToOne(() => Metric, (metric) => metric.key, { onDelete: 'CASCADE' }) public metric: Metric; diff --git a/frontend/projects/upgrade/src/app/core/analysis/store/analysis.models.ts b/frontend/projects/upgrade/src/app/core/analysis/store/analysis.models.ts index ec878e2d43..04d77430b1 100644 --- a/frontend/projects/upgrade/src/app/core/analysis/store/analysis.models.ts +++ b/frontend/projects/upgrade/src/app/core/analysis/store/analysis.models.ts @@ -1,7 +1,8 @@ import { AppState } from '../../core.module'; -import { OPERATION_TYPES, IMetricMetaData, IMetricUnit, REPEATED_MEASURE } from 'upgrade_types'; +import { IMetricUnit, ExperimentQueryPayload } from 'upgrade_types'; +import type { REPEATED_MEASURE } from 'upgrade_types'; -export { OPERATION_TYPES, IMetricMetaData, REPEATED_MEASURE }; +export { OPERATION_TYPES, IMetricMetaData, REPEATED_MEASURE } from 'upgrade_types'; export const METRICS_JOIN_TEXT = '@__@'; @@ -17,7 +18,7 @@ export interface UpsertMetrics { export interface Query { name: string; - query: any; + query: ExperimentQueryPayload; metric: { key: string; }; diff --git a/frontend/projects/upgrade/src/app/core/experiments/experiments.data.service.ts b/frontend/projects/upgrade/src/app/core/experiments/experiments.data.service.ts index 28ea434ae6..9c292f3b8c 100644 --- a/frontend/projects/upgrade/src/app/core/experiments/experiments.data.service.ts +++ b/frontend/projects/upgrade/src/app/core/experiments/experiments.data.service.ts @@ -5,6 +5,7 @@ import { ExperimentPaginationParams, UpdateExperimentFilterModeRequest, UpdateExperimentDecisionPointsRequest, + UpdateExperimentMetricsRequest, ExperimentSegmentListResponse, UpdateExperimentConditionsRequest, } from './store/experiments.model'; @@ -196,4 +197,12 @@ export class ExperimentDataService { const url = `${this.environment.api.exportAllExperimentIncludeLists}/${id}`; return this.http.get(url); } + + updateExperimentMetrics(params: UpdateExperimentMetricsRequest): Observable { + const updatedExperiment = { + ...params.experiment, + queries: params.metrics, + }; + return this.updateExperiment(updatedExperiment); + } } diff --git a/frontend/projects/upgrade/src/app/core/experiments/experiments.service.ts b/frontend/projects/upgrade/src/app/core/experiments/experiments.service.ts index 9276db779e..7f8719dfbc 100644 --- a/frontend/projects/upgrade/src/app/core/experiments/experiments.service.ts +++ b/frontend/projects/upgrade/src/app/core/experiments/experiments.service.ts @@ -15,6 +15,7 @@ import { UpdateExperimentFilterModeRequest, UpdateExperimentDecisionPointsRequest, UpdateExperimentConditionsRequest, + UpdateExperimentMetricsRequest, } from './store/experiments.model'; import { Store, select } from '@ngrx/store'; import { @@ -208,6 +209,10 @@ export class ExperimentService { this.store$.dispatch(experimentAction.actionUpdateExperimentConditions({ updateExperimentConditionsRequest })); } + updateExperimentMetrics(updateExperimentMetricsRequest: UpdateExperimentMetricsRequest) { + this.store$.dispatch(experimentAction.actionUpdateExperimentMetrics({ updateExperimentMetricsRequest })); + } + fetchContextMetaData() { this.store$.dispatch(experimentAction.actionFetchContextMetaData({ isLoadingContextMetaData: true })); } diff --git a/frontend/projects/upgrade/src/app/core/experiments/metric-helper.service.ts b/frontend/projects/upgrade/src/app/core/experiments/metric-helper.service.ts new file mode 100644 index 0000000000..b08afdbec2 --- /dev/null +++ b/frontend/projects/upgrade/src/app/core/experiments/metric-helper.service.ts @@ -0,0 +1,66 @@ +import { Injectable } from '@angular/core'; +import { v4 as uuidv4 } from 'uuid'; + +import { ExperimentService } from './experiments.service'; +import { Experiment, ExperimentQueryDTO, UpdateExperimentMetricsRequest } from './store/experiments.model'; + +@Injectable({ + providedIn: 'root', +}) +export class MetricHelperService { + constructor(private experimentService: ExperimentService) {} + + /** + * Add a new metric to an experiment + */ + addMetric(experiment: Experiment, metricData: ExperimentQueryDTO): void { + const currentMetrics = [...(experiment.queries || [])]; + const newMetric = { + ...metricData, + id: uuidv4(), + }; + + const updatedMetrics = [...currentMetrics, newMetric] as ExperimentQueryDTO[]; + + this.updateExperimentMetrics(experiment, updatedMetrics); + } + + /** + * Update an existing metric in an experiment + */ + updateMetric(experiment: Experiment, sourceMetric: ExperimentQueryDTO, metricData: ExperimentQueryDTO): void { + const currentMetrics = [...(experiment.queries || [])]; + const updatedMetrics = currentMetrics.map((metric) => + metric.id === sourceMetric.id + ? { + ...metric, + ...metricData, + } + : metric + ); + + this.updateExperimentMetrics(experiment, updatedMetrics); + } + + /** + * Delete a metric from an experiment + */ + deleteMetric(experiment: Experiment, metricToDelete: ExperimentQueryDTO): void { + const currentMetrics = [...(experiment.queries || [])]; + const updatedMetrics = currentMetrics.filter((metric) => metric.id !== metricToDelete.id); + + this.updateExperimentMetrics(experiment, updatedMetrics); + } + + /** + * Common method to update experiment metrics + */ + private updateExperimentMetrics(experiment: Experiment, updatedMetrics: ExperimentQueryDTO[]): void { + const updateRequest: UpdateExperimentMetricsRequest = { + experiment, + metrics: updatedMetrics, + }; + + this.experimentService.updateExperimentMetrics(updateRequest); + } +} diff --git a/frontend/projects/upgrade/src/app/core/experiments/store/experiments.actions.ts b/frontend/projects/upgrade/src/app/core/experiments/store/experiments.actions.ts index 3ccc4b69f6..39185fe898 100644 --- a/frontend/projects/upgrade/src/app/core/experiments/store/experiments.actions.ts +++ b/frontend/projects/upgrade/src/app/core/experiments/store/experiments.actions.ts @@ -13,6 +13,7 @@ import { IContextMetaData, UpdateExperimentFilterModeRequest, UpdateExperimentDecisionPointsRequest, + UpdateExperimentMetricsRequest, ExperimentSegmentListResponse, UpdateExperimentConditionsRequest, } from './experiments.model'; @@ -407,3 +408,15 @@ export const actionExportAllIncludeListsDesignSuccess = createAction( export const actionExportAllIncludeListsDesignFailure = createAction( '[Experiment] Export All Include Lists Design Failure' ); + +export const actionUpdateExperimentMetrics = createAction( + '[Experiment] Update Experiment Metrics', + props<{ updateExperimentMetricsRequest: UpdateExperimentMetricsRequest }>() +); + +export const actionUpdateExperimentMetricsSuccess = createAction( + '[Experiment] Update Experiment Metrics Success', + props<{ experiment: Experiment }>() +); + +export const actionUpdateExperimentMetricsFailure = createAction('[Experiment] Update Experiment Metrics Failure'); diff --git a/frontend/projects/upgrade/src/app/core/experiments/store/experiments.effects.ts b/frontend/projects/upgrade/src/app/core/experiments/store/experiments.effects.ts index 32f2c7219c..c2374ee708 100644 --- a/frontend/projects/upgrade/src/app/core/experiments/store/experiments.effects.ts +++ b/frontend/projects/upgrade/src/app/core/experiments/store/experiments.effects.ts @@ -273,6 +273,24 @@ export class ExperimentEffects { ) ); + updateExperimentMetrics$ = createEffect(() => + this.actions$.pipe( + ofType(experimentAction.actionUpdateExperimentMetrics), + switchMap((action) => { + return this.experimentDataService.updateExperimentMetrics(action.updateExperimentMetricsRequest).pipe( + map((experiment) => { + this.notificationService.showSuccess(this.translate.instant('experiments.metrics.update-success.text')); + return experimentAction.actionUpdateExperimentMetricsSuccess({ experiment }); + }), + catchError(() => { + this.notificationService.showError(this.translate.instant('experiments.metrics.update-error.text')); + return [experimentAction.actionUpdateExperimentMetricsFailure()]; + }) + ); + }) + ) + ); + deleteExperiment$ = createEffect(() => this.actions$.pipe( ofType(experimentAction.actionDeleteExperiment), diff --git a/frontend/projects/upgrade/src/app/core/experiments/store/experiments.model.ts b/frontend/projects/upgrade/src/app/core/experiments/store/experiments.model.ts index 43705016bf..adb47fbd6c 100644 --- a/frontend/projects/upgrade/src/app/core/experiments/store/experiments.model.ts +++ b/frontend/projects/upgrade/src/app/core/experiments/store/experiments.model.ts @@ -8,7 +8,6 @@ import { EXPERIMENT_SORT_KEY, SORT_AS_DIRECTION, EXPERIMENT_STATE, - IExperimentEnrollmentStats, IExperimentSearchParams, IExperimentSortParams, IExperimentEnrollmentDetailStats, @@ -24,6 +23,9 @@ import { REPEATED_MEASURE, SEGMENT_TYPE, IEnrollmentCompleteCondition, + METRIC_TYPE, + ExperimentQueryPayload, + ExperimentQueryComparator, } from 'upgrade_types'; import { Segment } from '../../segments/store/segments.model'; @@ -41,7 +43,8 @@ export { IExperimentSortParams, IExperimentEnrollmentDetailStats, DATE_RANGE, -}; + METRIC_TYPE, +} from 'upgrade_types'; export interface ExperimentConditionFilterOptions { code: string; @@ -326,8 +329,6 @@ export enum EXPERIMENT_BUTTON_ACTION { IMPORT_EXCLUDE_LIST = 'import exclude list', EXPORT_ALL_INCLUDE_LISTS = 'export all include lists', EXPORT_ALL_EXCLUDE_LISTS = 'export all exclude lists', - IMPORT_METRIC = 'import metric', - EXPORT_ALL_METRICS = 'export all metrics', } export interface UpsertExperimentParams { @@ -374,6 +375,19 @@ export interface ConditionFormData { description: string; } +export interface MetricFormData { + metricType: METRIC_TYPE; + metricId: string; + displayName: string; + metricClass?: string; // For repeatable metrics only + metricKey?: string; // For repeatable metrics only + aggregateStatistic?: string; + individualStatistic?: string; // For repeatable metrics only + comparison?: ExperimentQueryComparator; + compareValue?: string; + allowableDataKeys?: string[]; // For categorical metrics only +} + // Base interfaces matching backend DTO structure export interface ExperimentConditionDTO { id: string; @@ -417,7 +431,7 @@ export interface ExperimentConditionPayloadDTO { export interface ExperimentQueryDTO { id?: string; name: string; - query: object; + query: ExperimentQueryPayload; metric: { key: string; }; @@ -518,6 +532,11 @@ export interface UpdateExperimentConditionsRequest { conditions: ExperimentCondition[]; } +export interface UpdateExperimentMetricsRequest { + experiment: Experiment; + metrics: ExperimentQueryDTO[]; +} + export const EXPERIMENT_ROOT_COLUMN_NAMES = { NAME: 'name', STATUS: 'state', @@ -626,6 +645,11 @@ export interface ExperimentPayloadRowActionEvent { payload: ExperimentConditionPayload; } +export interface ExperimentQueryRowActionEvent { + action: EXPERIMENT_ROW_ACTION; + query: ExperimentQueryDTO; +} + export enum EXPERIMENT_PAYLOAD_DISPLAY_TYPE { UNIVERSAL = 'universal', SPECIFIC = 'specific', @@ -641,3 +665,11 @@ export interface InteractionEffectGraphData { export interface ExperimentSegmentListResponse extends SegmentNew { experiment: Experiment; } + +export interface UpsertMetricParams { + sourceQuery: ExperimentQueryDTO | null; + action: UPSERT_EXPERIMENT_ACTION; + experimentId: string; + currentContext?: string; + experimentInfo?: ExperimentVM; +} diff --git a/frontend/projects/upgrade/src/app/core/experiments/store/experiments.reducer.ts b/frontend/projects/upgrade/src/app/core/experiments/store/experiments.reducer.ts index 6f856e0b8c..ebd7bb6ab0 100644 --- a/frontend/projects/upgrade/src/app/core/experiments/store/experiments.reducer.ts +++ b/frontend/projects/upgrade/src/app/core/experiments/store/experiments.reducer.ts @@ -128,6 +128,14 @@ const reducer = createReducer( ...state, isLoadingExperiment: false, })), + on(experimentsAction.actionUpdateExperimentMetrics, (state) => ({ ...state, isLoadingExperiment: true })), + on(experimentsAction.actionUpdateExperimentMetricsSuccess, (state, { experiment }) => + adapter.upsertOne(experiment, { ...state, isLoadingExperiment: false }) + ), + on(experimentsAction.actionUpdateExperimentMetricsFailure, (state) => ({ + ...state, + isLoadingExperiment: false, + })), on(experimentsAction.actionFetchAllDecisionPointsSuccess, (state, { decisionPoints }) => ({ ...state, allDecisionPoints: decisionPoints, diff --git a/frontend/projects/upgrade/src/app/features/dashboard/experiments/modals/upsert-metric-modal/upsert-metric-modal.component.html b/frontend/projects/upgrade/src/app/features/dashboard/experiments/modals/upsert-metric-modal/upsert-metric-modal.component.html new file mode 100644 index 0000000000..e37987dbe4 --- /dev/null +++ b/frontend/projects/upgrade/src/app/features/dashboard/experiments/modals/upsert-metric-modal/upsert-metric-modal.component.html @@ -0,0 +1,257 @@ + +
+ + +
+ + + +
+ + Global Metric + + + {{ 'experiments.upsert-metric-modal.metric-type-global-metric-description.text' | translate }} + +
+
+ +
+ Repeatable Metric + + {{ 'experiments.upsert-metric-modal.metric-type-repeatable-metric-description.text' | translate }} + +
+
+
+
+ + + + + Metric Class + + + + {{ metricClass.key }} + + + + + + {{ 'experiments.upsert-metric-modal.metric-class-warning.text' | translate }} + + + {{ 'experiments.upsert-metric-modal.metric-class-hint.text' | translate }} + + + + + + + Metric Key + + + + {{ metricKey.key }} + + + + + + {{ 'experiments.upsert-metric-modal.metric-key-warning.text' | translate }} + + + {{ 'experiments.upsert-metric-modal.metric-key-hint.text' | translate }} + + + + + + + Metric ID + + + + {{ metric.key }} + + + + + + {{ 'experiments.upsert-metric-modal.metric-id-warning.text' | translate }} + + + {{ 'experiments.upsert-metric-modal.metric-id-hint.text' | translate }} + + + + + + + Individual Statistic + + + {{ option.label }} + + + + {{ 'experiments.upsert-metric-modal.individual-statistic-hint.text' | translate }} + + Learn more + + + + + + + + + Aggregate Statistic + + + {{ option.label }} + + + + {{ 'experiments.upsert-metric-modal.aggregate-statistic-hint.text' | translate }} + + Learn more + + + + + + + +
+ + + +
+ {{ option.label }} +
+
+
+
+
+ + + + + Value + + + {{ value }} + + + + {{ 'experiments.upsert-metric-modal.value-hint.text' | translate }} + + + + + + + Display Name + + + {{ 'experiments.upsert-metric-modal.display-name-hint.text' | translate }} + + +
+
\ No newline at end of file diff --git a/frontend/projects/upgrade/src/app/features/dashboard/experiments/modals/upsert-metric-modal/upsert-metric-modal.component.scss b/frontend/projects/upgrade/src/app/features/dashboard/experiments/modals/upsert-metric-modal/upsert-metric-modal.component.scss new file mode 100644 index 0000000000..fa641f89cf --- /dev/null +++ b/frontend/projects/upgrade/src/app/features/dashboard/experiments/modals/upsert-metric-modal/upsert-metric-modal.component.scss @@ -0,0 +1,8 @@ +.disabled-text { + color: rgba(0, 0, 0, 0.38) !important; +} + +.metric-type-section, +.comparison-section { + padding: 4px 0; +} \ No newline at end of file diff --git a/frontend/projects/upgrade/src/app/features/dashboard/experiments/modals/upsert-metric-modal/upsert-metric-modal.component.spec.ts b/frontend/projects/upgrade/src/app/features/dashboard/experiments/modals/upsert-metric-modal/upsert-metric-modal.component.spec.ts new file mode 100644 index 0000000000..92c8304234 --- /dev/null +++ b/frontend/projects/upgrade/src/app/features/dashboard/experiments/modals/upsert-metric-modal/upsert-metric-modal.component.spec.ts @@ -0,0 +1,209 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { BehaviorSubject, of } from 'rxjs'; +import { ReactiveFormsModule, FormBuilder } from '@angular/forms'; +import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; + +import { UpsertMetricModalComponent } from './upsert-metric-modal.component'; +import { + Experiment, + ExperimentQueryDTO, + MetricFormData, + UpsertMetricParams, + UPSERT_EXPERIMENT_ACTION, +} from '../../../../../core/experiments/store/experiments.model'; +import { METRICS_JOIN_TEXT } from '../../../../../core/analysis/store/analysis.models'; +import { + ExperimentQueryComparator, + IMetricMetaData, + IMetricUnit, + METRIC_TYPE, + OPERATION_TYPES, + REPEATED_MEASURE, +} from 'upgrade_types'; +import { CommonModalConfig } from '../../../../../shared-standalone-component-lib/components/common-modal/common-modal.types'; +import { AnalysisService } from '../../../../../core/analysis/analysis.service'; +import { ExperimentService } from '../../../../../core/experiments/experiments.service'; +import { MetricHelperService } from '../../../../../core/experiments/metric-helper.service'; +import { NotificationService } from '../../../../../core/notifications/notification.service'; + +describe('UpsertMetricModalComponent', () => { + let fixture: ComponentFixture; + let component: UpsertMetricModalComponent; + let analysisMetrics$: BehaviorSubject; + let experimentServiceStub: Partial; + let metricHelperServiceStub: MetricHelperService; + let notificationServiceStub: NotificationService; + + const defaultConfig: CommonModalConfig = { + title: 'Upsert Metric', + params: { + action: UPSERT_EXPERIMENT_ACTION.ADD, + experimentId: 'experiment-001', + sourceQuery: null, + }, + }; + + const dialogRefStub = { + close: jest.fn(), + } as unknown as MatDialogRef; + + const createComponent = (configOverride?: Partial>) => { + const mergedParams = configOverride?.params + ? { ...defaultConfig.params, ...configOverride.params } + : { ...defaultConfig.params }; + + const mergedConfig: CommonModalConfig = { + ...defaultConfig, + ...configOverride, + params: mergedParams, + }; + + TestBed.overrideProvider(MAT_DIALOG_DATA, { useValue: mergedConfig }); + + fixture = TestBed.createComponent(UpsertMetricModalComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + + return component; + }; + + beforeEach(async () => { + analysisMetrics$ = new BehaviorSubject([]); + + experimentServiceStub = { + isLoadingExperiment$: of(false), + selectedExperiment$: new BehaviorSubject(null), + experiments$: new BehaviorSubject([]), + updateExperimentMetrics: jest.fn(), + updateExperiment: jest.fn(), + }; + + metricHelperServiceStub = { + addMetric: jest.fn(), + updateMetric: jest.fn(), + } as unknown as MetricHelperService; + + notificationServiceStub = { + showError: jest.fn(), + } as unknown as NotificationService; + + await TestBed.configureTestingModule({ + imports: [UpsertMetricModalComponent, ReactiveFormsModule], + providers: [ + FormBuilder, + { provide: MAT_DIALOG_DATA, useValue: defaultConfig }, + { provide: MatDialogRef, useValue: dialogRefStub }, + { provide: AnalysisService, useValue: { allMetrics$: analysisMetrics$ } }, + { provide: ExperimentService, useValue: experimentServiceStub }, + { provide: MetricHelperService, useValue: metricHelperServiceStub }, + { provide: NotificationService, useValue: notificationServiceStub }, + ], + }) + .overrideComponent(UpsertMetricModalComponent, { + set: { template: '' }, + }) + .compileComponents(); + }); + + afterEach(() => { + TestBed.resetTestingModule(); + }); + + it('should initialize global metric defaults when adding a new metric', () => { + createComponent(); + + expect(component.metricForm.get('metricType')?.value).toBe(METRIC_TYPE.GLOBAL); + expect(component.metricForm.get('displayName')?.value).toBe(''); + expect(component.metricForm.get('comparison')?.value).toBe('='); + expect(component.metricForm.get('compareValue')?.value).toBe(''); + expect(component.showAggregateStatistic).toBe(false); + expect(component.showComparison).toBe(false); + }); + + it('should populate repeatable metric data when editing an existing query', () => { + const repeatableMetricTree: IMetricUnit = { + key: 'orders', + children: [ + { + key: 'status', + children: [ + { + key: 'completed', + metadata: { type: IMetricMetaData.CATEGORICAL }, + allowedData: ['GRADUATED', 'DROPPED'], + }, + ], + metadata: { type: IMetricMetaData.CATEGORICAL }, + }, + ], + metadata: { type: IMetricMetaData.CATEGORICAL }, + }; + + analysisMetrics$.next([repeatableMetricTree]); + + const sourceQuery: ExperimentQueryDTO = { + id: 'query-123', + name: 'Completion Rate', + metric: { + key: `orders${METRICS_JOIN_TEXT}status${METRICS_JOIN_TEXT}completed`, + }, + query: { + operationType: OPERATION_TYPES.COUNT, + compareFn: '=', + compareValue: 'GRADUATED', + }, + repeatedMeasure: REPEATED_MEASURE.mostRecent, + }; + + createComponent({ + params: { + action: UPSERT_EXPERIMENT_ACTION.EDIT, + experimentId: 'experiment-001', + sourceQuery, + currentContext: 'mathia', + }, + }); + + expect(component.metricForm.get('metricType')?.value).toBe(METRIC_TYPE.REPEATABLE); + + const metricClassControl = component.metricForm.get('metricClass')?.value as IMetricUnit; + expect(metricClassControl?.key).toBe('orders'); + + expect(component.metricForm.get('aggregateStatistic')?.value).toBe(OPERATION_TYPES.COUNT); + expect(component.metricForm.get('comparison')?.value as ExperimentQueryComparator).toBe('='); + expect(component.metricForm.get('compareValue')?.value).toBe('GRADUATED'); + expect(component.showAggregateStatistic).toBe(true); + expect(component.showComparison).toBe(true); + }); + + it('should build a categorical repeatable metric payload for the backend', () => { + createComponent(); + + component.metricDataType = IMetricMetaData.CATEGORICAL; + + const repeatableFormValue = { + metricType: METRIC_TYPE.REPEATABLE, + metricId: 'completed', + displayName: 'Completion Rate', + metricClass: 'orders', + metricKey: 'status', + aggregateStatistic: OPERATION_TYPES.COUNT, + individualStatistic: REPEATED_MEASURE.mean, + comparison: '=', + compareValue: 'GRADUATED', + } as MetricFormData & { + metricType: METRIC_TYPE.REPEATABLE; + aggregateStatistic: OPERATION_TYPES; + comparison: ExperimentQueryComparator; + }; + + const dto = (component as any).prepareMetricDataForBackend(repeatableFormValue); + + expect(dto.name).toBe('Completion Rate'); + expect(dto.metric.key).toBe(`orders${METRICS_JOIN_TEXT}status${METRICS_JOIN_TEXT}completed`); + expect(dto.query.operationType).toBe(OPERATION_TYPES.COUNT); + expect(dto.query.compareFn).toBe('='); + expect(dto.query.compareValue).toBe('GRADUATED'); + expect(dto.repeatedMeasure).toBe(REPEATED_MEASURE.mean); + }); +}); diff --git a/frontend/projects/upgrade/src/app/features/dashboard/experiments/modals/upsert-metric-modal/upsert-metric-modal.component.ts b/frontend/projects/upgrade/src/app/features/dashboard/experiments/modals/upsert-metric-modal/upsert-metric-modal.component.ts new file mode 100644 index 0000000000..2f597a6779 --- /dev/null +++ b/frontend/projects/upgrade/src/app/features/dashboard/experiments/modals/upsert-metric-modal/upsert-metric-modal.component.ts @@ -0,0 +1,1179 @@ +import { ChangeDetectionStrategy, ChangeDetectorRef, Component, Inject, OnDestroy, OnInit } from '@angular/core'; +import { + AbstractControl, + FormBuilder, + FormGroup, + ReactiveFormsModule, + ValidationErrors, + Validators, +} from '@angular/forms'; +import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; +import { BehaviorSubject, Observable, Subscription, combineLatest } from 'rxjs'; +import { combineLatestWith, filter, map, startWith, take } from 'rxjs/operators'; +import isEqual from 'lodash.isequal'; +import { CommonModule } from '@angular/common'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatInputModule } from '@angular/material/input'; +import { MatRadioModule } from '@angular/material/radio'; +import { MatSelectModule } from '@angular/material/select'; +import { MatChipsModule } from '@angular/material/chips'; +import { MatIconModule } from '@angular/material/icon'; +import { MatAutocompleteModule } from '@angular/material/autocomplete'; +import { TranslateModule } from '@ngx-translate/core'; + +import { CommonModalComponent } from '../../../../../shared-standalone-component-lib/components'; +import { CommonModalConfig } from '../../../../../shared-standalone-component-lib/components/common-modal/common-modal.types'; +import { CommonFormHelpersService } from '../../../../../shared/services/common-form-helpers.service'; +import { SharedModule } from '../../../../../shared/shared.module'; +import { + MetricFormData, + UPSERT_EXPERIMENT_ACTION, + UpsertMetricParams, + Experiment, + ExperimentQueryDTO, +} from '../../../../../core/experiments/store/experiments.model'; +import { ExperimentService } from '../../../../../core/experiments/experiments.service'; +import { MetricHelperService } from '../../../../../core/experiments/metric-helper.service'; +import { AnalysisService } from '../../../../../core/analysis/analysis.service'; +import { NotificationService } from '../../../../../core/notifications/notification.service'; +import { METRICS_JOIN_TEXT } from '../../../../../core/analysis/store/analysis.models'; +import { + ASSIGNMENT_UNIT, + IMetricMetaData, + IMetricUnit, + METRIC_TYPE, + OPERATION_TYPES, + REPEATED_MEASURE, + ExperimentQueryPayload, + ExperimentQueryComparator, +} from 'upgrade_types'; + +interface StatisticOption { + value: string; + label: string; +} + +type MetricNode = IMetricUnit; + +type MetricControlValue = MetricNode | string | string[] | null; + +interface MetricSelection { + classNode: MetricNode | null; + keyNode: MetricNode | null; + idNode: MetricNode | null; +} + +interface MetricFormValueBase { + metricType: METRIC_TYPE; + metricId: MetricControlValue; + displayName: string; + metricClass: MetricControlValue; + metricKey: MetricControlValue; + aggregateStatistic: OPERATION_TYPES | ''; + individualStatistic: REPEATED_MEASURE | ''; + comparison: ExperimentQueryComparator | ''; + compareValue: string; +} + +interface GlobalMetricFormValue extends MetricFormValueBase { + metricType: METRIC_TYPE.GLOBAL; +} + +interface RepeatableMetricFormValue extends MetricFormValueBase { + metricType: METRIC_TYPE.REPEATABLE; +} + +type MetricFormValue = GlobalMetricFormValue | RepeatableMetricFormValue; + +/** + * Presents the add/edit metric modal and normalizes form values for global and repeatable metrics. + * Centralizes the multi-step UX (autocomplete selections, validation, payload build) so callers only + * inject services and consume the resulting DTO sent back through the modal close. + */ +@Component({ + selector: 'upsert-metric-modal', + imports: [ + CommonModalComponent, + MatFormFieldModule, + MatInputModule, + MatRadioModule, + MatSelectModule, + MatChipsModule, + MatIconModule, + MatAutocompleteModule, + CommonModule, + ReactiveFormsModule, + TranslateModule, + SharedModule, + ], + templateUrl: './upsert-metric-modal.component.html', + styleUrl: './upsert-metric-modal.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class UpsertMetricModalComponent implements OnInit, OnDestroy { + isLoadingUpsertMetric$ = this.experimentService.isLoadingExperiment$; + + subscriptions = new Subscription(); + isPrimaryButtonDisabled$: Observable; + isInitialFormValueChanged$: Observable; + + initialFormValues$ = new BehaviorSubject(null); + + metricForm: FormGroup; + showMetricClass = false; + showMetricKey = false; + showAggregateStatistic = false; + showIndividualStatistic = false; + showComparison = false; + metricDataType: IMetricMetaData | null = null; + isGlobalMetricDisabled = false; + + // Dropdown options + aggregateStatisticOptions: StatisticOption[] = []; + individualStatisticOptions: StatisticOption[] = []; + + // Autocomplete + allMetrics$ = this.analysisService.allMetrics$; + allMetrics: MetricNode[] = []; + + // Filtered autocomplete observables + filteredMetricClasses$: Observable; + filteredMetricKeys$: Observable; + filteredMetricIds$: Observable; + + // BehaviorSubjects for source data + private readonly metricClassOptions$ = new BehaviorSubject([]); + private readonly metricKeyOptions$ = new BehaviorSubject([]); + private readonly metricIdOptions$ = new BehaviorSubject([]); + + // Track if user has selected a valid option from autocomplete (vs just typing) + private readonly hasValidMetricClassSelection$ = new BehaviorSubject(false); + private readonly hasValidMetricKeySelection$ = new BehaviorSubject(false); + private readonly hasValidMetricIdSelection$ = new BehaviorSubject(false); + + // Assignment unit and context for filtering + private currentAssignmentUnit: ASSIGNMENT_UNIT | null = null; + private currentContext: string[] | null = null; + private currentExperiment: Experiment | null = null; + + allowableDataKeys: string[] = []; + comparisonOptions: Array<{ value: ExperimentQueryComparator; label: string }> = [ + { value: '=', label: 'Equal' }, + { value: '<>', label: 'Not equal' }, + ]; + + continuousAggregateOptions: StatisticOption[] = [ + { value: OPERATION_TYPES.SUM, label: 'Sum' }, + { value: OPERATION_TYPES.MIN, label: 'Min' }, + { value: OPERATION_TYPES.MAX, label: 'Max' }, + { value: OPERATION_TYPES.COUNT, label: 'Count' }, + { value: OPERATION_TYPES.AVERAGE, label: 'Mean' }, + { value: OPERATION_TYPES.MODE, label: 'Mode' }, + { value: OPERATION_TYPES.MEDIAN, label: 'Median' }, + { value: OPERATION_TYPES.STDEV, label: 'Standard Deviation' }, + ]; + + continuousIndividualOptions: StatisticOption[] = [ + { value: REPEATED_MEASURE.mean, label: 'Mean' }, + { value: REPEATED_MEASURE.earliest, label: 'Earliest' }, + { value: REPEATED_MEASURE.mostRecent, label: 'Most Recent' }, + ]; + + categoricalAggregateOptions: StatisticOption[] = [ + { value: OPERATION_TYPES.COUNT, label: 'Count' }, + { value: OPERATION_TYPES.PERCENTAGE, label: 'Percentage' }, + ]; + + categoricalIndividualOptions: StatisticOption[] = [ + { value: REPEATED_MEASURE.earliest, label: 'Earliest' }, + { value: REPEATED_MEASURE.mostRecent, label: 'Most Recent' }, + ]; + + constructor( + @Inject(MAT_DIALOG_DATA) + public config: CommonModalConfig, + private readonly formBuilder: FormBuilder, + private readonly experimentService: ExperimentService, + private readonly metricHelperService: MetricHelperService, + private readonly analysisService: AnalysisService, + private readonly notificationService: NotificationService, + private readonly cdr: ChangeDetectorRef, + public readonly dialogRef: MatDialogRef + ) {} + + private getCurrentFormValue(): MetricFormValue { + return this.metricForm.getRawValue() as MetricFormValue; + } + + private isRepeatableFormValue(value: MetricFormValue): value is RepeatableMetricFormValue { + return value.metricType === METRIC_TYPE.REPEATABLE; + } + + private getNodeKey(node: MetricNode | null | undefined): string { + if (!node) { + return ''; + } + + return Array.isArray(node.key) ? node.key.join(METRICS_JOIN_TEXT) : node.key ?? ''; + } + + private toMetricFormData( + formValue: MetricFormValue, + allowableDataKeys: string[] = this.allowableDataKeys + ): MetricFormData { + return { + metricType: formValue.metricType, + metricId: this.extractKey(formValue.metricId), + displayName: formValue.displayName, + metricClass: this.extractKey(formValue.metricClass), + metricKey: this.extractKey(formValue.metricKey), + aggregateStatistic: formValue.aggregateStatistic, + individualStatistic: formValue.individualStatistic, + comparison: formValue.comparison || undefined, + compareValue: formValue.compareValue, + allowableDataKeys, + }; + } + + /** + * Bootstraps the reactive form, wiring together data sources, listeners, and initial state so the + * UI reflects the selected experiment context and metric being edited (if any). + */ + ngOnInit(): void { + this.createMetricForm(); + this.setupFormChangeListeners(); + this.setupAutocomplete(); + this.setupExperimentContext(); + + // Add listeners AFTER form is fully set up + this.listenForIsInitialFormValueChanged(); + this.listenForPrimaryButtonDisabled(); + } + + ngOnDestroy(): void { + this.subscriptions.unsubscribe(); + } + + // Custom validator to ensure fields are selected from options (object) not typed (string) + private autocompleteSelectionValidator(control: AbstractControl): ValidationErrors | null { + const value = control.value; + if (!value) { + return null; // Let required validator handle empty values + } + // Valid if it's an object (selected from autocomplete) + if (typeof value === 'object' && value !== null) { + return null; + } + // Invalid if it's a string (typed, not selected) + return { mustSelectFromOptions: true }; + } + + /** + * Builds the reactive form with validators that reflect the current modal mode (add vs edit) and + * metric type (global vs repeatable). When editing, seeds controls with parsed source query values. + */ + createMetricForm(): void { + const { sourceQuery, action } = this.config.params; + const initialValues = this.deriveInitialFormValues(sourceQuery, action); + + this.metricForm = this.formBuilder.group({ + metricType: [initialValues.metricType, Validators.required], + metricId: [initialValues.metricId, [Validators.required, this.autocompleteSelectionValidator.bind(this)]], + displayName: [initialValues.displayName, Validators.required], + metricClass: [initialValues.metricClass, this.autocompleteSelectionValidator.bind(this)], + metricKey: [initialValues.metricKey, this.autocompleteSelectionValidator.bind(this)], + aggregateStatistic: [initialValues.aggregateStatistic], + individualStatistic: [initialValues.individualStatistic], + comparison: [initialValues.comparison || '='], + compareValue: [initialValues.compareValue], + }); + + this.allowableDataKeys = initialValues.allowableDataKeys || []; + this.initialFormValues$.next(initialValues); + + // Set initial form visibility states - detect metric data type first if we have a metric ID + if (initialValues.metricId) { + this.detectMetricDataType(initialValues.metricId); + this.updateStatisticOptions(); + } + this.updateFormVisibility(); + this.updateMetricTypeAvailability(); + + // For edit mode, populate form after allMetrics are loaded + if (action === UPSERT_EXPERIMENT_ACTION.EDIT && sourceQuery) { + this.populateFormForEditMode(initialValues); + } + } + + /** + * Translates the incoming query into form-ready values, handling both global and repeatable key formats. + */ + deriveInitialFormValues(sourceQuery: ExperimentQueryDTO | null, action: UPSERT_EXPERIMENT_ACTION): MetricFormData { + if (action === UPSERT_EXPERIMENT_ACTION.EDIT && sourceQuery) { + const metricKey = sourceQuery.metric?.key || ''; + const aggregateStatistic = sourceQuery.query?.operationType ?? ''; + const comparison = sourceQuery.query?.compareFn ?? '='; + const compareValue = sourceQuery.query?.compareValue ?? ''; + + // The correct way to determine if it's repeatable is by checking if the metric key contains METRICS_JOIN_TEXT + // NOT by checking if repeatedMeasure exists (global metrics can also have individual statistics) + const isRepeatable = metricKey.includes(METRICS_JOIN_TEXT); + + let metricClass = ''; + let metricKeyValue = ''; + let metricId = ''; + + if (isRepeatable) { + // Parse combined key for repeatable metrics: "class@__@key@__@id" + const keyParts = metricKey.split(METRICS_JOIN_TEXT); + if (keyParts.length === 3) { + metricClass = keyParts[0]; + metricKeyValue = keyParts[1]; + metricId = keyParts[2]; + } else { + // Fallback if format is unexpected + metricId = metricKey; + } + } else { + // Global metric: use the key as-is for metricId + metricId = metricKey; + } + + return { + metricType: isRepeatable ? METRIC_TYPE.REPEATABLE : METRIC_TYPE.GLOBAL, + metricId, + displayName: sourceQuery.name || '', + metricClass, + metricKey: metricKeyValue, + aggregateStatistic, + individualStatistic: sourceQuery.repeatedMeasure || '', + comparison, + compareValue, + allowableDataKeys: [], + }; + } + + // Default values for add mode + return { + metricType: METRIC_TYPE.GLOBAL, + metricId: '', + displayName: '', + metricClass: '', + metricKey: '', + aggregateStatistic: '', + individualStatistic: '', + comparison: '=', + compareValue: '', + allowableDataKeys: [], + }; + } + + /** + * When editing, waits for available metrics so the form can rehydrate selections with real node objects + * instead of raw strings, restoring validator state and statistic dropdowns accurately. + */ + populateFormForEditMode(initialValues: MetricFormData): void { + // Wait for allMetrics to be loaded, then populate form with proper objects + this.subscriptions.add( + this.allMetrics$ + .pipe( + filter((metrics) => Array.isArray(metrics) && metrics.length > 0), + take(1) + ) + .subscribe((metrics) => { + const metricObjects = this.findMetricObjects(metrics, initialValues); + this.updateFormWithMetricObjects(initialValues, metricObjects); + + // Only update validation state and initialize statistics if idObject exists + if (metricObjects.idNode) { + this.updateValidationState(metricObjects); + this.initializeMetricTypeAndStatistics(metricObjects.idNode); + } + + this.cdr.markForCheck(); + }) + ); + } + + private findMetricObjects(metrics: MetricNode[], initialValues: MetricFormData): MetricSelection { + const { metricType, metricClass, metricKey, metricId } = initialValues; + + if (metricType === METRIC_TYPE.REPEATABLE) { + return this.findRepeatableMetricObjects(metrics, metricClass, metricKey, metricId); + } + + // Global metric: find the metric directly + return { + classNode: null, + keyNode: null, + idNode: metrics.find((m) => this.extractKey(m) === metricId) ?? null, + }; + } + + private findRepeatableMetricObjects( + metrics: MetricNode[], + metricClass: string, + metricKey: string, + metricId: string + ): MetricSelection { + const classNode = metrics.find((m) => this.extractKey(m) === metricClass) ?? null; + + if (!classNode?.children) { + return { classNode, keyNode: null, idNode: null }; + } + + const keyNode = classNode.children?.find((k) => this.extractKey(k) === metricKey) ?? null; + + if (!keyNode) { + return { classNode, keyNode: null, idNode: null }; + } + + // Find ID object in key's children, or use key itself if no children + const idNode = keyNode.children?.length + ? keyNode.children.find((id) => this.extractKey(id) === metricId) ?? null + : keyNode; + + return { classNode, keyNode, idNode }; + } + + private updateFormWithMetricObjects(initialValues: MetricFormData, metricObjects: MetricSelection): void { + const { classNode, keyNode, idNode } = metricObjects; + + // Update form with found objects (fallback to string values if not found) + const formControlUpdates = { + metricClass: classNode ?? initialValues.metricClass, + metricKey: keyNode ?? initialValues.metricKey, + metricId: idNode ?? initialValues.metricId, + }; + + this.metricForm.patchValue(formControlUpdates); + this.populateOptions(); + + // Update initial form values once with all necessary data + const normalizedInitialValues = this.toMetricFormData( + this.getCurrentFormValue(), + this.allowableDataKeys.length > 0 ? this.allowableDataKeys : initialValues.allowableDataKeys ?? [] + ); + + if (!isEqual(this.initialFormValues$.value, normalizedInitialValues)) { + this.initialFormValues$.next(normalizedInitialValues); + } + } + + private updateValidationState(metricObjects: MetricSelection): void { + const { classNode, keyNode, idNode } = metricObjects; + + // Mark selections as valid based on found objects + if (classNode) { + this.hasValidMetricClassSelection$.next(true); + } + if (keyNode) { + this.hasValidMetricKeySelection$.next(true); + } + if (idNode) { + this.hasValidMetricIdSelection$.next(true); + } + } + + private initializeMetricTypeAndStatistics(idNode: MetricControlValue): void { + this.detectMetricDataType(idNode); + this.updateStatisticOptions(); + this.clearInvalidStatisticSelections(); + this.updateFormVisibility(); + this.updateFormValidators(); + + // Re-sync allowableDataKeys to initial values after detection + // This prevents false "changed" detection for categorical metrics + if (this.allowableDataKeys.length > 0) { + const syncedInitialValues: MetricFormData = { + ...this.initialFormValues$.value, + allowableDataKeys: this.allowableDataKeys, + }; + if (!isEqual(this.initialFormValues$.value, syncedInitialValues)) { + this.initialFormValues$.next(syncedInitialValues); + } + } + } + + setupFormChangeListeners(): void { + this.subscriptions.add( + this.metricForm.get('metricType')?.valueChanges.subscribe(() => { + this.onMetricTypeChange(); + }) + ); + + this.subscriptions.add( + this.metricForm.get('metricClass')?.valueChanges.subscribe((selectedClass) => { + this.onMetricClassValueChange(selectedClass as MetricControlValue); + }) + ); + + this.subscriptions.add( + this.metricForm.get('metricKey')?.valueChanges.subscribe((selectedKey) => { + this.onMetricKeyValueChange(selectedKey as MetricControlValue); + }) + ); + + this.subscriptions.add( + this.metricForm.get('metricId')?.valueChanges.subscribe((metricId) => { + this.onMetricIdValueChange(metricId as MetricControlValue); + }) + ); + } + + setupAutocomplete(): void { + this.subscriptions.add( + this.allMetrics$.subscribe((metrics) => { + this.allMetrics = Array.isArray(metrics) ? (metrics as MetricNode[]) : []; + this.populateOptions(); + this.createFilteredObservables(); + }) + ); + } + + setupExperimentContext(): void { + this.subscriptions.add( + this.experimentService.selectedExperiment$.subscribe((experiment) => { + if (experiment) { + this.currentExperiment = experiment; + this.currentAssignmentUnit = experiment.assignmentUnit; + this.currentContext = experiment.context; + this.updateMetricTypeAvailability(); + this.populateOptions(); + } + }) + ); + + if (this.config.params.experimentId && !this.currentAssignmentUnit) { + this.subscriptions.add( + this.experimentService.experiments$.subscribe((experiments) => { + const experiment = experiments.find((exp) => exp.id === this.config.params.experimentId); + if (experiment && !this.currentAssignmentUnit) { + this.currentExperiment = experiment; + this.currentAssignmentUnit = experiment.assignmentUnit; + this.currentContext = experiment.context; + this.updateMetricTypeAvailability(); + this.populateOptions(); + } + }) + ); + } + } + + populateOptions(): void { + if (!this.metricForm) { + return; + } + + const { metricType } = this.getCurrentFormValue(); + const filteredMetrics = this.filterMetricsByAssignmentContext(this.allMetrics || []); + + if (metricType === METRIC_TYPE.GLOBAL) { + this.populateGlobalMetricOptions(filteredMetrics); + return; + } + + this.populateRepeatableMetricOptions(filteredMetrics); + } + + createFilteredObservables(): void { + const metricClassControl = this.metricForm.get('metricClass'); + const metricKeyControl = this.metricForm.get('metricKey'); + const metricIdControl = this.metricForm.get('metricId'); + + const metricClassChanges$ = metricClassControl + ? metricClassControl.valueChanges.pipe(startWith((metricClassControl.value ?? null) as MetricControlValue)) + : new BehaviorSubject(null); + + const metricKeyChanges$ = metricKeyControl + ? metricKeyControl.valueChanges.pipe(startWith((metricKeyControl.value ?? null) as MetricControlValue)) + : new BehaviorSubject(null); + + const metricIdChanges$ = metricIdControl + ? metricIdControl.valueChanges.pipe(startWith((metricIdControl.value ?? null) as MetricControlValue)) + : new BehaviorSubject(null); + + this.filteredMetricClasses$ = combineLatest([metricClassChanges$, this.metricClassOptions$]).pipe( + map(([searchValue, options]) => this._filter(searchValue as MetricControlValue, options)) + ); + + this.filteredMetricKeys$ = combineLatest([metricKeyChanges$, this.metricKeyOptions$]).pipe( + map(([searchValue, options]) => this._filter(searchValue as MetricControlValue, options)) + ); + + this.filteredMetricIds$ = combineLatest([metricIdChanges$, this.metricIdOptions$]).pipe( + map(([searchValue, options]) => this._filter(searchValue as MetricControlValue, options)) + ); + } + + onMetricClassValueChange(selectedClass: MetricControlValue): void { + // If user is typing (string value) after selecting an option, invalidate the selection + if ( + (typeof selectedClass === 'string' || Array.isArray(selectedClass)) && + this.hasValidMetricClassSelection$.getValue() + ) { + this.hasValidMetricClassSelection$.next(false); + // Clear dependent fields when parent becomes invalid + this.metricKeyOptions$.next([]); + this.metricIdOptions$.next([]); + this.metricForm.get('metricKey')?.setValue(''); + this.metricForm.get('metricId')?.setValue(''); + this.hasValidMetricKeySelection$.next(false); + this.hasValidMetricIdSelection$.next(false); + } + + // If field is cleared completely + if (!selectedClass) { + this.hasValidMetricClassSelection$.next(false); + this.metricKeyOptions$.next([]); + this.metricIdOptions$.next([]); + this.metricForm.get('metricKey')?.setValue(''); + this.metricForm.get('metricId')?.setValue(''); + this.hasValidMetricKeySelection$.next(false); + this.hasValidMetricIdSelection$.next(false); + } + } + + onMetricClassOptionSelected(selectedClass: MetricNode): void { + if (selectedClass && typeof selectedClass === 'object' && selectedClass.children) { + this.hasValidMetricClassSelection$.next(true); + const classChildren: MetricNode[] = Array.isArray(selectedClass.children) ? selectedClass.children : []; + this.metricKeyOptions$.next(classChildren); + + // Reset dependent fields + this.metricForm.get('metricKey')?.setValue(''); + this.metricForm.get('metricId')?.setValue(''); + this.metricIdOptions$.next([]); + this.hasValidMetricKeySelection$.next(false); + this.hasValidMetricIdSelection$.next(false); + } + } + + onMetricKeyValueChange(selectedKey: MetricControlValue): void { + // If user is typing (string value) after selecting an option, invalidate the selection + if ( + (typeof selectedKey === 'string' || Array.isArray(selectedKey)) && + this.hasValidMetricKeySelection$.getValue() + ) { + this.hasValidMetricKeySelection$.next(false); + // Clear dependent fields when parent becomes invalid + this.metricIdOptions$.next([]); + this.metricForm.get('metricId')?.setValue(''); + this.hasValidMetricIdSelection$.next(false); + } + + // If field is cleared completely + if (!selectedKey) { + this.hasValidMetricKeySelection$.next(false); + this.metricIdOptions$.next([]); + this.metricForm.get('metricId')?.setValue(''); + this.hasValidMetricIdSelection$.next(false); + } + } + + onMetricKeyOptionSelected(selectedKey: MetricNode): void { + if (selectedKey && typeof selectedKey === 'object') { + this.hasValidMetricKeySelection$.next(true); + + // Set metric IDs based on selected key's children + this.metricIdOptions$.next(this.getMetricIdOptionsFromKey(selectedKey)); + + // Reset ID field + this.metricForm.get('metricId')?.setValue(''); + this.hasValidMetricIdSelection$.next(false); + } + } + + displayFn = (option?: MetricControlValue): string => { + if (!option) { + return ''; + } + + if (typeof option === 'string') { + return option; + } + + if (Array.isArray(option)) { + return option.join(', '); + } + + const key = this.getNodeKey(option); + return key.includes(METRICS_JOIN_TEXT) ? key.split(METRICS_JOIN_TEXT).join(' / ') : key; + }; + + private extractKey(value: MetricControlValue): string { + if (!value) { + return ''; + } + + if (typeof value === 'string') { + return value; + } + + if (Array.isArray(value)) { + return value.join(METRICS_JOIN_TEXT); + } + + return this.getNodeKey(value); + } + + private _filter(value: MetricControlValue, options: MetricNode[]): MetricNode[] { + const filterValue = this.extractKey(value).toLowerCase(); + return options.filter((option) => { + const optionKey = this.getNodeKey(option); + return optionKey?.toLowerCase().includes(filterValue) ?? false; + }); + } + + private findOptionByKey(options: MetricNode[], key: string): MetricNode | undefined { + if (!options.length || !key) { + return undefined; + } + return options.find((option) => this.getNodeKey(option) === key); + } + + private resolveSelectedOption(controlValue: MetricControlValue, options: MetricNode[] = []): MetricNode | undefined { + if (!controlValue) { + return undefined; + } + if (Array.isArray(controlValue)) { + return this.findOptionByKey(options, controlValue.join(METRICS_JOIN_TEXT)); + } + + if (typeof controlValue === 'object') { + return controlValue; + } + return this.findOptionByKey(options, this.extractKey(controlValue)); + } + + private getMetricIdOptionsFromKey(selectedKey: MetricControlValue): MetricNode[] { + if (!selectedKey || typeof selectedKey !== 'object' || Array.isArray(selectedKey)) { + return []; + } + + if (selectedKey.children && selectedKey.children.length > 0) { + return Array.isArray(selectedKey.children) ? selectedKey.children : []; + } + + return [selectedKey]; + } + + private filterMetricsByAssignmentContext(metrics: MetricNode[]): MetricNode[] { + if (!metrics?.length) { + return []; + } + + if (this.currentAssignmentUnit === ASSIGNMENT_UNIT.WITHIN_SUBJECTS) { + const withinSubjectsMetrics = metrics.filter((metric) => metric.children && metric.children.length > 0); + return withinSubjectsMetrics.length > 0 ? withinSubjectsMetrics : metrics; + } + + if (this.currentAssignmentUnit && this.currentContext?.length) { + const contextFilteredMetrics = metrics.filter( + (metric) => metric.context && this.currentContext?.some((ctx) => metric.context.includes(ctx)) + ); + if (contextFilteredMetrics.length > 0) { + return contextFilteredMetrics; + } + } + + return metrics; + } + + private populateGlobalMetricOptions(metrics: MetricNode[]): void { + this.metricClassOptions$.next([]); + this.metricKeyOptions$.next([]); + const globalMetrics = metrics.filter((metric) => !metric.children || metric.children.length === 0); + this.metricIdOptions$.next(globalMetrics); + } + + private populateRepeatableMetricOptions(metrics: MetricNode[]): void { + const repeatableMetrics = metrics.filter((metric) => metric.children && metric.children.length > 0); + this.metricClassOptions$.next(repeatableMetrics); + + const selectedClassValue = this.metricForm.get('metricClass')?.value as MetricControlValue; + const selectedClass = this.resolveSelectedOption(selectedClassValue, repeatableMetrics); + if (!selectedClass?.children?.length) { + this.metricKeyOptions$.next([]); + this.metricIdOptions$.next([]); + return; + } + + const classChildren: MetricNode[] = Array.isArray(selectedClass.children) ? selectedClass.children : []; + this.metricKeyOptions$.next(classChildren); + + const selectedKeyValue = this.metricForm.get('metricKey')?.value as MetricControlValue; + const selectedKey = this.resolveSelectedOption(selectedKeyValue, classChildren); + if (!selectedKey) { + this.metricIdOptions$.next([]); + return; + } + + this.metricIdOptions$.next(this.getMetricIdOptionsFromKey(selectedKey)); + } + + onMetricTypeChange(): void { + // Clear everything and repopulate + this.metricDataType = null; + this.hasValidMetricClassSelection$.next(false); + this.hasValidMetricKeySelection$.next(false); + this.hasValidMetricIdSelection$.next(false); + + // Clear form fields + this.metricForm.get('metricClass')?.setValue(''); + this.metricForm.get('metricKey')?.setValue(''); + this.metricForm.get('metricId')?.setValue(''); + this.metricForm.get('displayName')?.setValue(''); // Clear display name when metric type changes + + // Repopulate options based on new metric type + // BehaviorSubjects will automatically trigger observable re-emission + this.populateOptions(); + + // Update form state + this.resetStatisticFields(); + this.updateFormVisibility(); + this.updateFormValidators(); + } + + onMetricIdValueChange(metricId: MetricControlValue): void { + // If user is typing (string or string[] value) after selecting an option, invalidate the selection + if ((typeof metricId === 'string' || Array.isArray(metricId)) && this.hasValidMetricIdSelection$.getValue()) { + this.hasValidMetricIdSelection$.next(false); + this.metricDataType = null; + this.hideStatisticDropdowns(); + this.resetStatisticFields(); + this.updateFormValidators(); + } + + // If field is cleared completely + if (!metricId) { + this.hasValidMetricIdSelection$.next(false); + this.metricDataType = null; + this.hideStatisticDropdowns(); + this.resetStatisticFields(); + this.updateFormValidators(); + } + } + + onMetricIdOptionSelected(metricId: MetricNode): void { + // This is called when user actually selects an option from autocomplete + if (metricId && typeof metricId === 'object') { + this.hasValidMetricIdSelection$.next(true); + this.detectMetricDataType(metricId); + this.updateStatisticOptions(); + this.clearInvalidStatisticSelections(); + this.updateFormVisibility(); + this.updateFormValidators(); + } + } + + detectMetricDataType(metricId: MetricControlValue): void { + const selectedMetric = this.findSelectedMetric(metricId); + + // Use metadata if available + if (selectedMetric?.metadata?.type) { + this.setMetricDataType(selectedMetric.metadata.type, selectedMetric); + return; + } + + // Fallback to heuristic detection + this.detectMetricTypeByHeuristic(metricId); + } + + private findSelectedMetric(metricId: MetricControlValue): MetricNode | null { + if (metricId && typeof metricId === 'object' && !Array.isArray(metricId) && metricId.metadata) { + return metricId; + } + + if (typeof metricId === 'string') { + const currentOptions = this.metricIdOptions$.getValue(); + return currentOptions.find((metric) => this.getNodeKey(metric) === metricId) ?? null; + } + + if (Array.isArray(metricId)) { + const joinedKey = metricId.join(METRICS_JOIN_TEXT); + return this.metricIdOptions$.getValue().find((metric) => this.getNodeKey(metric) === joinedKey) ?? null; + } + + return null; + } + + private setMetricDataType(dataType: IMetricMetaData, selectedMetric?: MetricNode): void { + this.metricDataType = dataType; + + if (dataType === IMetricMetaData.CATEGORICAL) { + this.allowableDataKeys = selectedMetric?.allowedData ? [...selectedMetric.allowedData] : []; + } else { + this.allowableDataKeys = []; + } + } + + private detectMetricTypeByHeuristic(metricId: MetricControlValue): void { + const metricKey = this.extractKey(metricId); + const lowerMetricKey = metricKey.toLowerCase(); + + const continuousKeywords = ['time', 'count', 'score', 'number', 'seconds', 'minutes', 'duration']; + const categoricalKeywords = ['status', 'type', 'category', 'level', 'completion']; + + if (continuousKeywords.some((keyword) => lowerMetricKey.includes(keyword))) { + this.setMetricDataType(IMetricMetaData.CONTINUOUS); + } else if (categoricalKeywords.some((keyword) => lowerMetricKey.includes(keyword))) { + this.setMetricDataType(IMetricMetaData.CATEGORICAL); + } else { + // Default to continuous for unknown types + this.setMetricDataType(IMetricMetaData.CONTINUOUS); + } + } + + updateFormVisibility(): void { + const { metricType } = this.getCurrentFormValue(); + + // Base visibility for metric type + this.showMetricClass = metricType === METRIC_TYPE.REPEATABLE; + this.showMetricKey = metricType === METRIC_TYPE.REPEATABLE; + + // Statistics only show when a valid metric ID option has been selected + if (this.hasValidMetricIdSelection$.getValue() && this.metricDataType) { + this.showAggregateStatistic = true; + this.showIndividualStatistic = metricType === METRIC_TYPE.REPEATABLE; + this.showComparison = this.metricDataType === IMetricMetaData.CATEGORICAL; + } else { + this.showAggregateStatistic = false; + this.showIndividualStatistic = false; + this.showComparison = false; + } + + // Trigger change detection with OnPush strategy + this.cdr.markForCheck(); + } + + updateMetricTypeAvailability(): void { + // Disable global metrics for within-subjects experiments + this.isGlobalMetricDisabled = this.currentAssignmentUnit === ASSIGNMENT_UNIT.WITHIN_SUBJECTS; + + // If global metrics are disabled and global is currently selected, switch to repeatable + const { metricType } = this.getCurrentFormValue(); + if (this.isGlobalMetricDisabled && metricType === METRIC_TYPE.GLOBAL) { + this.metricForm.get('metricType')?.setValue(METRIC_TYPE.REPEATABLE); + } + + this.cdr.markForCheck(); + } + + updateStatisticOptions(): void { + if (this.metricDataType === IMetricMetaData.CONTINUOUS) { + this.aggregateStatisticOptions = this.continuousAggregateOptions; + this.individualStatisticOptions = this.continuousIndividualOptions; + } else if (this.metricDataType === IMetricMetaData.CATEGORICAL) { + this.aggregateStatisticOptions = this.categoricalAggregateOptions; + this.individualStatisticOptions = this.categoricalIndividualOptions; + } + // Note: showComparison is handled in updateFormVisibility() + } + + private clearInvalidStatisticSelections(): void { + if (!this.metricForm || !this.hasValidMetricIdSelection$.getValue()) { + return; + } + + const aggregateControl = this.metricForm.get('aggregateStatistic'); + const individualControl = this.metricForm.get('individualStatistic'); + + const validAggregateValues = this.aggregateStatisticOptions.map((option) => option.value); + const currentAggregateValue = aggregateControl?.value; + + if (currentAggregateValue && !validAggregateValues.includes(currentAggregateValue)) { + aggregateControl?.setValue('', { emitEvent: false }); + } + + const validIndividualValues = this.individualStatisticOptions.map((option) => option.value); + const currentIndividualValue = individualControl?.value; + + if (currentIndividualValue && !validIndividualValues.includes(currentIndividualValue)) { + individualControl?.setValue('', { emitEvent: false }); + } + } + + hideStatisticDropdowns(): void { + this.showAggregateStatistic = false; + this.showIndividualStatistic = false; + this.showComparison = false; + } + + resetStatisticFields(): void { + this.metricForm.patchValue({ + aggregateStatistic: '', + individualStatistic: '', + comparison: '=', + compareValue: '', + }); + this.allowableDataKeys = []; + } + + updateFormValidators(): void { + const { metricType } = this.getCurrentFormValue(); + + // Update validators based on metric type + if (metricType === METRIC_TYPE.REPEATABLE) { + this.metricForm + .get('metricClass') + ?.setValidators([Validators.required, this.autocompleteSelectionValidator.bind(this)]); + this.metricForm + .get('metricKey') + ?.setValidators([Validators.required, this.autocompleteSelectionValidator.bind(this)]); + this.metricForm.get('individualStatistic')?.setValidators([Validators.required]); + } else { + this.metricForm.get('metricClass')?.clearValidators(); + this.metricForm.get('metricKey')?.clearValidators(); + this.metricForm.get('individualStatistic')?.clearValidators(); + } + + // Update aggregate statistic validator + if (this.showAggregateStatistic) { + this.metricForm.get('aggregateStatistic')?.setValidators([Validators.required]); + } else { + this.metricForm.get('aggregateStatistic')?.clearValidators(); + } + + // Update comparison validators for categorical metrics + if (this.showComparison) { + this.metricForm.get('comparison')?.setValidators([Validators.required]); + this.metricForm.get('compareValue')?.setValidators([Validators.required]); + } else { + this.metricForm.get('comparison')?.clearValidators(); + this.metricForm.get('compareValue')?.clearValidators(); + } + + // Update all validators without emitting events to prevent recursion + for (const key of Object.keys(this.metricForm.controls)) { + this.metricForm.get(key)?.updateValueAndValidity({ emitEvent: false }); + } + + // Update the form's overall validity status WITH event emission + // This ensures the observable streams are updated for button state + this.metricForm.updateValueAndValidity({ emitEvent: true }); + } + + /** + * Emits whether the user has diverged from the original payload so the modal can toggle its action button. + */ + listenForIsInitialFormValueChanged(): void { + this.isInitialFormValueChanged$ = this.metricForm.valueChanges.pipe( + startWith(this.getCurrentFormValue()), + map((value) => value as MetricFormValue), + map((formValue) => this.toMetricFormData(formValue)), + map((normalizedValue) => !isEqual(normalizedValue, this.initialFormValues$.value)) + ); + } + + /** + * Combines form validity, loading status, and selection completeness to drive the primary button state. + */ + listenForPrimaryButtonDisabled(): void { + this.isPrimaryButtonDisabled$ = this.isLoadingUpsertMetric$.pipe( + combineLatestWith(this.isInitialFormValueChanged$, this.hasValidMetricIdSelection$), + map( + ([isLoading, isInitialFormValueChanged, hasValidMetricIdSelection]) => + isLoading || + this.metricForm.invalid || + !hasValidMetricIdSelection || + (!isInitialFormValueChanged && this.config.params.action === UPSERT_EXPERIMENT_ACTION.EDIT) + ) + ); + } + + onPrimaryActionBtnClicked(): void { + if (this.metricForm.valid) { + this.sendUpsertMetricRequest(); + } else { + CommonFormHelpersService.triggerTouchedToDisplayErrors(this.metricForm); + } + } + + /** + * Resolves the target experiment and dispatches the normalized metric payload through the helper service. + */ + sendUpsertMetricRequest(): void { + const formValue = this.getCurrentFormValue(); + const metricData = this.prepareMetricDataForBackend(formValue); + + const experiment = this.currentExperiment; + + if (experiment) { + this.executeMetricUpsert(experiment, metricData); + return; + } + + this.experimentService.selectedExperiment$.pipe(take(1)).subscribe((selectedExperiment) => { + if (!selectedExperiment) { + this.notificationService.showError('Unable to load the selected experiment. Please refresh and try again.'); + return; + } + + const resolvedExperiment = selectedExperiment as Experiment; + this.currentExperiment = resolvedExperiment; + this.executeMetricUpsert(resolvedExperiment, metricData); + }); + } + + private executeMetricUpsert(experiment: Experiment, metricData: ExperimentQueryDTO): void { + if (this.config.params.action === UPSERT_EXPERIMENT_ACTION.ADD) { + this.metricHelperService.addMetric(experiment, metricData); + this.closeModal(); + return; + } + + const sourceQuery = this.config.params.sourceQuery; + if (!sourceQuery) { + this.notificationService.showError('Unable to update the metric because required query details are missing.'); + return; + } + + this.metricHelperService.updateMetric(experiment, sourceQuery, metricData); + this.closeModal(); + } + + /** + * Normalizes the form value into the backend DTO, joining repeatable keys and attaching categorical filters + * only when required by the selected metric metadata. + */ + private prepareMetricDataForBackend(formValue: MetricFormValue): ExperimentQueryDTO { + const metricKey = this.isRepeatableFormValue(formValue) + ? `${this.extractKey(formValue.metricClass)}${METRICS_JOIN_TEXT}${this.extractKey( + formValue.metricKey + )}${METRICS_JOIN_TEXT}${this.extractKey(formValue.metricId)}` + : this.extractKey(formValue.metricId); + + const repeatedMeasure = this.isRepeatableFormValue(formValue) + ? formValue.individualStatistic || REPEATED_MEASURE.mostRecent + : REPEATED_MEASURE.mostRecent; + + const operationType = formValue.aggregateStatistic as OPERATION_TYPES; + + const queryPayload: ExperimentQueryPayload = { + operationType, + ...(this.metricDataType === IMetricMetaData.CATEGORICAL && + formValue.comparison && + formValue.compareValue && { + compareFn: formValue.comparison, + compareValue: formValue.compareValue, + }), + }; + + // Prepare query object + const queryObj: ExperimentQueryDTO = { + name: formValue.displayName, + query: queryPayload, + metric: { + key: metricKey, + }, + repeatedMeasure, + }; + + return queryObj; + } + + closeModal(): void { + this.dialogRef.close(); + } +} diff --git a/frontend/projects/upgrade/src/app/features/dashboard/experiments/pages/experiment-details-page/experiment-details-page-content/experiment-metrics-section-card/experiment-metrics-section-card.component.html b/frontend/projects/upgrade/src/app/features/dashboard/experiments/pages/experiment-details-page/experiment-details-page-content/experiment-metrics-section-card/experiment-metrics-section-card.component.html index fc35bca7cd..b4b5cc9aa9 100644 --- a/frontend/projects/upgrade/src/app/features/dashboard/experiments/pages/experiment-details-page/experiment-details-page-content/experiment-metrics-section-card/experiment-metrics-section-card.component.html +++ b/frontend/projects/upgrade/src/app/features/dashboard/experiments/pages/experiment-details-page/experiment-details-page-content/experiment-metrics-section-card/experiment-metrics-section-card.component.html @@ -12,11 +12,8 @@ header-right [showPrimaryButton]="(permissions$ | async)?.experiments.update" [primaryButtonText]="'experiments.details.add-metric.button.text' | translate" - [showMenuButton]="(permissions$ | async)?.experiments.update" - [menuButtonItems]="menuButtonItems" [isSectionCardExpanded]="isSectionCardExpanded" (primaryButtonClick)="onAddMetricClick()" - (menuButtonItemClick)="onMenuButtonItemClick($event, experiment)" (sectionCardExpandChange)="onSectionCardExpandChange($event)" > diff --git a/frontend/projects/upgrade/src/app/features/dashboard/experiments/pages/experiment-details-page/experiment-details-page-content/experiment-metrics-section-card/experiment-metrics-section-card.component.ts b/frontend/projects/upgrade/src/app/features/dashboard/experiments/pages/experiment-details-page/experiment-details-page-content/experiment-metrics-section-card/experiment-metrics-section-card.component.ts index 1cd573efc1..fd4ac63281 100644 --- a/frontend/projects/upgrade/src/app/features/dashboard/experiments/pages/experiment-details-page/experiment-details-page-content/experiment-metrics-section-card/experiment-metrics-section-card.component.ts +++ b/frontend/projects/upgrade/src/app/features/dashboard/experiments/pages/experiment-details-page/experiment-details-page-content/experiment-metrics-section-card/experiment-metrics-section-card.component.ts @@ -6,20 +6,22 @@ import { } from '../../../../../../../shared-standalone-component-lib/components'; import { CommonModule } from '@angular/common'; import { TranslateModule } from '@ngx-translate/core'; -import { IMenuButtonItem } from 'upgrade_types'; import { ExperimentMetricsTableComponent, ExperimentQueryRowActionEvent, } from './experiment-metrics-table/experiment-metrics-table.component'; import { ExperimentService } from '../../../../../../../core/experiments/experiments.service'; +import { MetricHelperService } from '../../../../../../../core/experiments/metric-helper.service'; import { Observable, map } from 'rxjs'; +import { take } from 'rxjs/operators'; import { Experiment, - EXPERIMENT_BUTTON_ACTION, EXPERIMENT_ROW_ACTION, + ExperimentQueryDTO, } from '../../../../../../../core/experiments/store/experiments.model'; import { UserPermission } from '../../../../../../../core/auth/store/auth.models'; import { AuthService } from '../../../../../../../core/auth/auth.service'; +import { DialogService } from '../../../../../../../shared/services/common-dialog.service'; @Component({ selector: 'app-experiment-metrics-section-card', @@ -50,43 +52,24 @@ export class ExperimentMetricsSectionCardComponent implements OnInit { return count; } - menuButtonItems: IMenuButtonItem[] = [ - { - label: 'experiments.details.import-metric.menu-item.text', - action: EXPERIMENT_BUTTON_ACTION.IMPORT_METRIC, - disabled: false, - }, - { - label: 'experiments.details.export-all-metrics.menu-item.text', - action: EXPERIMENT_BUTTON_ACTION.EXPORT_ALL_METRICS, - disabled: false, - }, - ]; - - constructor(private experimentService: ExperimentService, private authService: AuthService) {} + constructor( + private readonly experimentService: ExperimentService, + private readonly metricHelperService: MetricHelperService, + private readonly authService: AuthService, + private readonly dialogService: DialogService + ) {} ngOnInit() { this.permissions$ = this.authService.userPermissions$; } onAddMetricClick(): void { - // TODO: Implement add metric functionality when dialog service is available - console.log('Add metric clicked'); - } - - onMenuButtonItemClick(event: string, experiment: Experiment): void { - switch (event) { - case EXPERIMENT_BUTTON_ACTION.IMPORT_METRIC: - // TODO: Implement import functionality when dialog service is available - console.log('Import metric clicked for experiment:', experiment.id); - break; - case EXPERIMENT_BUTTON_ACTION.EXPORT_ALL_METRICS: - // TODO: Implement export functionality when experiment service methods are available - console.log('Export all metrics clicked for experiment:', experiment.id); - break; - default: - console.log('Unknown action:', event); - } + // Get experiment ID from selected experiment + this.selectedExperiment$.pipe(take(1)).subscribe((experiment: Experiment) => { + if (experiment?.id) { + this.dialogService.openAddMetricModal(experiment.id); + } + }); } onSectionCardExpandChange(isSectionCardExpanded: boolean): void { @@ -99,20 +82,31 @@ export class ExperimentMetricsSectionCardComponent implements OnInit { this.onEditMetric(event.query, experimentId); break; case EXPERIMENT_ROW_ACTION.DELETE: - this.onDeleteMetric(event.query, experimentId); + this.onDeleteMetric(event.query); break; default: console.log('Unknown action:', event.action); } } - private onEditMetric(query: any, experimentId: string): void { - // TODO: Implement edit metric functionality when dialog service is available - console.log('Edit metric clicked for query:', query.id, 'in experiment:', experimentId); + private onEditMetric(query: ExperimentQueryDTO, experimentId: string): void { + this.dialogService.openEditMetricModal(query, experimentId); } - private onDeleteMetric(query: any, experimentId: string): void { - // TODO: Implement delete metric functionality when dialog service is available - console.log('Delete metric clicked for query:', query.id, 'in experiment:', experimentId); + private onDeleteMetric(query: ExperimentQueryDTO): void { + const metricDisplayName = query.name || `${query.metric?.key}`; + + this.dialogService + .openDeleteMetricModal(metricDisplayName) + .afterClosed() + .subscribe((confirmClicked) => { + if (confirmClicked) { + this.selectedExperiment$.pipe(take(1)).subscribe((experiment: Experiment) => { + if (experiment) { + this.metricHelperService.deleteMetric(experiment, query); + } + }); + } + }); } } diff --git a/frontend/projects/upgrade/src/app/shared/services/common-dialog.service.ts b/frontend/projects/upgrade/src/app/shared/services/common-dialog.service.ts index 4bdecc1a29..867ba28160 100644 --- a/frontend/projects/upgrade/src/app/shared/services/common-dialog.service.ts +++ b/frontend/projects/upgrade/src/app/shared/services/common-dialog.service.ts @@ -38,11 +38,13 @@ import { DeleteSegmentModalComponent } from '../../features/dashboard/segments/m import { UpsertExperimentModalComponent } from '../../features/dashboard/experiments/modals/upsert-experiment-modal/upsert-experiment-modal.component'; import { UpsertDecisionPointModalComponent } from '../../features/dashboard/experiments/modals/upsert-decision-point-modal/upsert-decision-point-modal.component'; import { UpsertConditionModalComponent } from '../../features/dashboard/experiments/modals/upsert-condition-modal/upsert-condition-modal.component'; +import { UpsertMetricModalComponent } from '../../features/dashboard/experiments/modals/upsert-metric-modal/upsert-metric-modal.component'; import { UPSERT_EXPERIMENT_ACTION, ExperimentDecisionPoint, ExperimentCondition, ExperimentConditionPayload, + ExperimentQueryDTO, Experiment, UpsertExperimentParams, WeightingMethod, @@ -83,6 +85,12 @@ export interface UpsertConditionModalParams { context: string; } +export interface UpsertMetricModalParams { + sourceQuery: ExperimentQueryDTO | null; + action: UPSERT_EXPERIMENT_ACTION; + experimentId: string; +} + @Injectable({ providedIn: 'root', }) @@ -245,6 +253,46 @@ export class DialogService { return this.dialog.open(EditPayloadModalComponent, config); } + openAddMetricModal(experimentId: string) { + const commonModalConfig: CommonModalConfig = { + title: 'Add Metric', + primaryActionBtnLabel: 'Create', + primaryActionBtnColor: 'primary', + cancelBtnLabel: 'Cancel', + params: { + sourceQuery: null, + action: UPSERT_EXPERIMENT_ACTION.ADD, + experimentId, + }, + }; + return this.openUpsertMetricModal(commonModalConfig); + } + + openEditMetricModal(sourceQuery: ExperimentQueryDTO, experimentId: string) { + const commonModalConfig: CommonModalConfig = { + title: 'Edit Metric', + primaryActionBtnLabel: 'Save', + primaryActionBtnColor: 'primary', + cancelBtnLabel: 'Cancel', + params: { + sourceQuery: { ...sourceQuery }, + action: UPSERT_EXPERIMENT_ACTION.EDIT, + experimentId, + }, + }; + return this.openUpsertMetricModal(commonModalConfig); + } + + openUpsertMetricModal(commonModalConfig: CommonModalConfig) { + const config: MatDialogConfig = { + data: commonModalConfig, + width: ModalSize.STANDARD, + autoFocus: false, + disableClose: true, + }; + return this.dialog.open(UpsertMetricModalComponent, config); + } + // feature flag modal ---------------------------------------- // openAddFeatureFlagModal() { const commonModalConfig: CommonModalConfig = { @@ -735,6 +783,20 @@ export class DialogService { return this.openSimpleCommonConfirmationModal(deleteConditionModalConfig, ModalSize.SMALL); } + openDeleteMetricModal(metricName: string) { + const deleteMetricModalConfig: CommonModalConfig = { + title: 'Delete Metric', + primaryActionBtnLabel: 'Delete', + primaryActionBtnColor: 'warn', + cancelBtnLabel: 'Cancel', + params: { + message: `Are you sure you want to delete the metric "${metricName}"?`, + }, + }; + + return this.openSimpleCommonConfirmationModal(deleteMetricModalConfig, ModalSize.SMALL); + } + openDeleteSegmentModal() { const commonModalConfig: CommonModalConfig = { title: 'Delete Segment', diff --git a/frontend/projects/upgrade/src/assets/i18n/en.json b/frontend/projects/upgrade/src/assets/i18n/en.json index f1dbf54b04..e993fe5050 100644 --- a/frontend/projects/upgrade/src/assets/i18n/en.json +++ b/frontend/projects/upgrade/src/assets/i18n/en.json @@ -469,6 +469,18 @@ "experiments.details.metrics.display-name.text": "Display Name", "experiments.details.metrics.actions.text": "Actions", "experiments.details.metrics.card.no-data-row.text": "No metrics defined. No metrics will be monitored in the experiment.", + "experiments.upsert-metric-modal.metric-type-global-metric-description.text": "Used for globally accumulated measures (e.g., total time spent using the app).", + "experiments.upsert-metric-modal.metric-type-repeatable-metric-description.text": "Used for repeatable measures (e.g., score on a quiz that can be taken multiple times).", + "experiments.upsert-metric-modal.metric-class-hint.text": "Categorizes what type of app component is being measured (e.g., workspace).", + "experiments.upsert-metric-modal.metric-class-warning.text": "Please select a Metric Class from the available options.", + "experiments.upsert-metric-modal.metric-key-hint.text": "Specifies the specific instance of the metric class being measured (e.g., problem ID).", + "experiments.upsert-metric-modal.metric-key-warning.text": "Please select a Metric Key from the available options.", + "experiments.upsert-metric-modal.metric-id-hint.text": "Specifies the data type (continuous or categorical) and what data to measure.", + "experiments.upsert-metric-modal.metric-id-warning.text": "Please select a Metric ID from the available options.", + "experiments.upsert-metric-modal.individual-statistic-hint.text": "The individual statistic determines which value to use for each student.", + "experiments.upsert-metric-modal.aggregate-statistic-hint.text": "The aggregate statistic determines how to combine values across all students.", + "experiments.upsert-metric-modal.value-hint.text": "The categorical metric data type requires you to specify which allowed value to measure.", + "experiments.upsert-metric-modal.display-name-hint.text": "The display name is used to refer to this metric in the experiment UI.", "experiments.details.enrollment-data.card.title.text": "Enrollment Data", "experiments.details.enrollment-data.card.subtitle.text": "Enrollments reflect participants who have started the experiment.", "experiments.details.export-enrollment-data.menu-item.text": "Export Enrollment Data", diff --git a/types/src/Experiment/enums.ts b/types/src/Experiment/enums.ts index eca1109073..6c0a173eea 100644 --- a/types/src/Experiment/enums.ts +++ b/types/src/Experiment/enums.ts @@ -187,6 +187,11 @@ export enum UserRole { READER = 'reader', } +export enum METRIC_TYPE { + GLOBAL = 'global', + REPEATABLE = 'repeatable', +} + export enum OPERATION_TYPES { SUM = 'sum', COUNT = 'count', diff --git a/types/src/Experiment/interfaces.ts b/types/src/Experiment/interfaces.ts index ffdd0343b8..e0ac3ce96c 100644 --- a/types/src/Experiment/interfaces.ts +++ b/types/src/Experiment/interfaces.ts @@ -15,6 +15,7 @@ import { IMPORT_COMPATIBILITY_TYPE, SERVER_ERROR, EXPERIMENT_LIST_OPERATION, + OPERATION_TYPES, } from './enums'; export interface IEnrollmentCompleteCondition { userCount: number; @@ -148,6 +149,14 @@ export interface IMetricUnit { allowedData?: string[]; } +export type ExperimentQueryComparator = '=' | '<>' | '!='; + +export interface ExperimentQueryPayload { + operationType: OPERATION_TYPES; + compareFn?: ExperimentQueryComparator; + compareValue?: string; +} + export interface IFlagVariation { id: string; value: string; diff --git a/types/src/index.ts b/types/src/index.ts index 0fb716b978..5608693804 100644 --- a/types/src/index.ts +++ b/types/src/index.ts @@ -14,6 +14,7 @@ export { EXPERIMENT_LIST_OPERATION, SORT_AS_DIRECTION, UserRole, + METRIC_TYPE, OPERATION_TYPES, IMetricMetaData, DATE_RANGE, @@ -83,6 +84,8 @@ export { FeatureFlagDeletedData, ValidatedImportResponse, DuplicateSegmentNameError, + ExperimentQueryPayload, + ExperimentQueryComparator, } from './Experiment/interfaces'; export { MoocletPolicyParametersDTO,