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 592a5771c9..c848e6f6cb 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, } from './store/experiments.model'; import { HttpClient, HttpParams } from '@angular/common/http'; @@ -165,4 +166,12 @@ export class ExperimentDataService { }; return this.updateExperiment(updatedExperiment); } + + 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 e3da2c5808..dca913ee5c 100644 --- a/frontend/projects/upgrade/src/app/core/experiments/experiments.service.ts +++ b/frontend/projects/upgrade/src/app/core/experiments/experiments.service.ts @@ -14,6 +14,7 @@ import { AddExperimentRequest, UpdateExperimentFilterModeRequest, UpdateExperimentDecisionPointsRequest, + UpdateExperimentMetricsRequest, } from './store/experiments.model'; import { Store, select } from '@ngrx/store'; import { @@ -199,6 +200,10 @@ export class ExperimentService { ); } + 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 f094bc0a8b..2d2b8523fc 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, } from './experiments.model'; import { ExperimentSegmentListRequest, ExperimentSegmentListDetails } from '../../segments/store/segments.model'; @@ -116,6 +117,18 @@ export const actionUpdateExperimentDecisionPointsFailure = createAction( '[Experiment] Update Experiment Decision Points 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'); + export const actionFetchAllDecisionPoints = createAction('[Experiment] Fetch All Decision Points'); export const actionFetchAllDecisionPointsSuccess = createAction( 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 eaa28786de..b353516e5e 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 @@ -240,6 +240,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 8d7e43586b..0e6d8cbbcc 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 @@ -24,6 +24,7 @@ import { REPEATED_MEASURE, SEGMENT_TYPE, IEnrollmentCompleteCondition, + METRIC_TYPE, } from 'upgrade_types'; import { Segment } from '../../segments/store/segments.model'; @@ -41,6 +42,7 @@ export { IExperimentSortParams, IExperimentEnrollmentDetailStats, DATE_RANGE, + METRIC_TYPE, }; export interface ExperimentConditionFilterOptions { @@ -361,6 +363,20 @@ export interface DecisionPointFormData { excludeIfReached: boolean; } +export interface MetricFormData { + metricType: METRIC_TYPE; + metricId: string; + displayName: string; + description?: string; + metricClass?: string; // For repeatable metrics only + metricKey?: string; // For repeatable metrics only + aggregateStatistic?: string; + individualStatistic?: string; // For repeatable metrics only + comparison?: string; + compareValue?: string; + allowableDataKeys?: string[]; // For categorical metrics only +} + // Base interfaces matching backend DTO structure export interface ExperimentConditionDTO { id: string; @@ -500,6 +516,11 @@ export interface UpdateExperimentDecisionPointsRequest { decisionPoints: ExperimentDecisionPoint[]; } +export interface UpdateExperimentMetricsRequest { + experiment: Experiment; + metrics: ExperimentQueryDTO[]; +} + export const EXPERIMENT_ROOT_COLUMN_NAMES = { NAME: 'name', STATUS: 'state', @@ -602,6 +623,11 @@ export interface ExperimentConditionRowActionEvent { condition: ExperimentCondition; } +export interface ExperimentQueryRowActionEvent { + action: EXPERIMENT_ROW_ACTION; + query: ExperimentQueryDTO; +} + export enum EXPERIMENT_PAYLOAD_DISPLAY_TYPE { UNIVERSAL = 'universal', SPECIFIC = 'specific', @@ -623,3 +649,11 @@ export interface RewardMetricData { 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 48b45921ae..3d1a779f7f 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 @@ -106,6 +106,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..3cd20385fa --- /dev/null +++ b/frontend/projects/upgrade/src/app/features/dashboard/experiments/modals/upsert-metric-modal/upsert-metric-modal.component.html @@ -0,0 +1,218 @@ + +
+ + +
+ + + +
+ + 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-hint.text' | translate }} + + + + + + + Metric Key + + + + {{ metricKey.key }} + + + + {{ 'experiments.upsert-metric-modal.metric-key-hint.text' | translate }} + + + + + + + Metric ID + + + + {{ metric.key }} + + + + {{ '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 }} + + + + + + Description (optional) + + + +
+
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..8511a0cc14 --- /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; +} 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..be69d35adc --- /dev/null +++ b/frontend/projects/upgrade/src/app/features/dashboard/experiments/modals/upsert-metric-modal/upsert-metric-modal.component.ts @@ -0,0 +1,773 @@ +import { ChangeDetectionStrategy, ChangeDetectorRef, Component, Inject, OnDestroy, OnInit } from '@angular/core'; +import { FormBuilder, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms'; +import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; +import { BehaviorSubject, Observable, Subscription, combineLatest } from 'rxjs'; +import { combineLatestWith, 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 { + 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 { METRICS_JOIN_TEXT } from '../../../../../core/analysis/store/analysis.models'; +import { ASSIGNMENT_UNIT, IMetricMetaData, METRIC_TYPE, OPERATION_TYPES, REPEATED_MEASURE } from 'upgrade_types'; + +interface StatisticOption { + value: string; + label: string; +} + +@Component({ + selector: 'upsert-metric-modal', + imports: [ + CommonModalComponent, + MatFormFieldModule, + MatInputModule, + MatRadioModule, + MatSelectModule, + MatChipsModule, + MatIconModule, + MatAutocompleteModule, + CommonModule, + ReactiveFormsModule, + TranslateModule, + ], + 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: any[] = []; + + // Filtered autocomplete observables + filteredMetricClasses$: Observable; + filteredMetricKeys$: Observable; + filteredMetricIds$: Observable; + + // BehaviorSubjects for source data + private metricClassOptions$ = new BehaviorSubject([]); + private metricKeyOptions$ = new BehaviorSubject([]); + private metricIdOptions$ = new BehaviorSubject([]); + + // Current selections + private currentSelectedClass: any = null; + private currentSelectedKey: any = null; + + // Assignment unit and context for filtering + private currentAssignmentUnit: ASSIGNMENT_UNIT | null = null; + private currentContext: string[] | null = null; + + allowableDataKeys: string[] = []; + comparisonOptions = [ + { 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 formBuilder: FormBuilder, + private experimentService: ExperimentService, + private metricHelperService: MetricHelperService, + private analysisService: AnalysisService, + private cdr: ChangeDetectorRef, + public dialogRef: MatDialogRef + ) {} + + 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(); + } + + 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], + displayName: [initialValues.displayName, Validators.required], + description: [initialValues.description], + metricClass: [initialValues.metricClass], + metricKey: [initialValues.metricKey], + 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); + } + } + + deriveInitialFormValues(sourceQuery: any, action: UPSERT_EXPERIMENT_ACTION): MetricFormData { + if (action === UPSERT_EXPERIMENT_ACTION.EDIT && sourceQuery) { + const metricKey = sourceQuery.metric?.key || ''; + + // 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 || '', + description: '', // Not available in current structure + metricClass, + metricKey: metricKeyValue, + aggregateStatistic: sourceQuery.query?.operationType || '', + individualStatistic: sourceQuery.repeatedMeasure || '', + comparison: sourceQuery.query?.compareFn || '=', + compareValue: sourceQuery.query?.compareValue || '', + allowableDataKeys: [], + }; + } + + // Default values for add mode + return { + metricType: METRIC_TYPE.GLOBAL, + metricId: '', + displayName: '', + description: '', + metricClass: '', + metricKey: '', + aggregateStatistic: '', + individualStatistic: '', + comparison: '=', + compareValue: '', + allowableDataKeys: [], + }; + } + + populateFormForEditMode(initialValues: MetricFormData): void { + // Wait for allMetrics to be loaded, then populate form with proper objects + this.subscriptions.add( + this.allMetrics$.pipe(take(1)).subscribe((metrics) => { + if (!metrics || metrics.length === 0) return; + + const { metricType, metricClass, metricKey, metricId } = initialValues; + let classObject = null; + let keyObject = null; + let idObject = null; + + if (metricType === METRIC_TYPE.REPEATABLE) { + // Find the class object + classObject = metrics.find((m) => m.key === metricClass); + + if (classObject?.children) { + // Find the key object within the class children + keyObject = classObject.children.find((k) => k.key === metricKey); + + if (keyObject?.children) { + // Find the ID object within the key children + idObject = keyObject.children.find((id) => id.key === metricId); + } else if (keyObject) { + // If no children in keyObject, keyObject itself might be the ID + idObject = keyObject; + } + } + } else { + // Global metric: find the metric directly + idObject = metrics.find((m) => m.key === metricId); + } + + // Update form with found objects (or keep strings if objects not found) + const formUpdates = { + metricClass: classObject || metricClass, + metricKey: keyObject || metricKey, + metricId: idObject || metricId, + }; + + this.metricForm.patchValue(formUpdates); + + // Update the initial form values to reflect the object values + const currentInitialValues = this.initialFormValues$.value; + const newInitialValues = { ...currentInitialValues, ...formUpdates }; + if (!isEqual(currentInitialValues, newInitialValues)) { + this.initialFormValues$.next(newInitialValues); + } + + // Update the options and form state + this.populateOptions(); + if (idObject) { + this.detectMetricDataType(idObject); + this.updateStatisticOptions(); + this.updateFormVisibility(); + } + + // Trigger change detection to ensure UI updates + this.cdr.markForCheck(); + }) + ); + } + + setupFormChangeListeners(): void { + this.subscriptions.add( + this.metricForm.get('metricType')?.valueChanges.subscribe(() => { + this.onMetricTypeChange(); + }) + ); + + this.subscriptions.add( + this.metricForm.get('metricClass')?.valueChanges.subscribe((selectedClass) => { + this.onMetricClassChange(selectedClass); + }) + ); + + this.subscriptions.add( + this.metricForm.get('metricKey')?.valueChanges.subscribe((selectedKey) => { + this.onMetricKeyChange(selectedKey); + }) + ); + + this.subscriptions.add( + this.metricForm.get('metricId')?.valueChanges.subscribe((metricId) => { + this.onMetricIdChange(metricId); + }) + ); + } + + setupAutocomplete(): void { + this.subscriptions.add( + this.allMetrics$.subscribe((metrics) => { + this.allMetrics = metrics || []; + this.populateOptions(); + this.createFilteredObservables(); + }) + ); + } + + setupExperimentContext(): void { + this.subscriptions.add( + this.experimentService.selectedExperiment$.subscribe((experiment) => { + if (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.currentAssignmentUnit = experiment.assignmentUnit; + this.currentContext = experiment.context; + this.updateMetricTypeAvailability(); + this.populateOptions(); + } + }) + ); + } + } + + populateOptions(): void { + const metricType = this.metricForm.get('metricType')?.value; + let filteredMetrics = this.allMetrics || []; + + if (this.currentAssignmentUnit && filteredMetrics.length > 0) { + if (this.currentAssignmentUnit === ASSIGNMENT_UNIT.WITHIN_SUBJECTS) { + const withinSubjectsMetrics = filteredMetrics.filter((metric) => metric.children && metric.children.length > 0); + if (withinSubjectsMetrics.length > 0) { + filteredMetrics = withinSubjectsMetrics; + } + } else if (this.currentContext && this.currentContext.length > 0) { + const contextFilteredMetrics = filteredMetrics.filter( + (metric) => metric.context && this.currentContext?.some((ctx) => metric.context.includes(ctx)) + ); + if (contextFilteredMetrics.length > 0) { + filteredMetrics = contextFilteredMetrics; + } + } + } + + if (metricType === METRIC_TYPE.GLOBAL) { + this.metricClassOptions$.next([]); + this.metricKeyOptions$.next([]); + const globalMetrics = filteredMetrics.filter((metric) => !metric.children || metric.children.length === 0); + this.metricIdOptions$.next(globalMetrics); + } else { + const repeatableMetrics = filteredMetrics.filter((metric) => metric.children && metric.children.length > 0); + this.metricClassOptions$.next(repeatableMetrics); + this.metricKeyOptions$.next([]); + this.metricIdOptions$.next([]); + } + } + + createFilteredObservables(): void { + this.filteredMetricClasses$ = combineLatest([ + this.metricForm.get('metricClass')?.valueChanges.pipe(startWith('')) || new BehaviorSubject(''), + this.metricClassOptions$, + ]).pipe(map(([searchValue, options]) => this._filter(searchValue || '', options))); + + this.filteredMetricKeys$ = combineLatest([ + this.metricForm.get('metricKey')?.valueChanges.pipe(startWith('')) || new BehaviorSubject(''), + this.metricKeyOptions$, + ]).pipe(map(([searchValue, options]) => this._filter(searchValue || '', options))); + + this.filteredMetricIds$ = combineLatest([ + this.metricForm.get('metricId')?.valueChanges.pipe(startWith('')) || new BehaviorSubject(''), + this.metricIdOptions$, + ]).pipe(map(([searchValue, options]) => this._filter(searchValue || '', options))); + } + + onMetricClassChange(selectedClass: any): void { + if (selectedClass && typeof selectedClass === 'object' && selectedClass.children) { + this.currentSelectedClass = selectedClass; + this.metricKeyOptions$.next(selectedClass.children); + + // Reset dependent fields - no forced refresh + this.metricForm.get('metricKey')?.setValue(''); + this.metricForm.get('metricId')?.setValue(''); + this.currentSelectedKey = null; + this.metricIdOptions$.next([]); + } else if (selectedClass === '' || selectedClass === null) { + // Clear everything if class is cleared + this.currentSelectedClass = null; + this.metricKeyOptions$.next([]); + this.metricIdOptions$.next([]); + this.metricForm.get('metricKey')?.setValue(''); + this.metricForm.get('metricId')?.setValue(''); + } + // Don't do anything for string values - user is typing + } + + onMetricKeyChange(selectedKey: any): void { + if (selectedKey && typeof selectedKey === 'object') { + this.currentSelectedKey = selectedKey; + + // Set metric IDs based on selected key's children + if (selectedKey.children && selectedKey.children.length > 0) { + this.metricIdOptions$.next(selectedKey.children); + } else { + this.metricIdOptions$.next([selectedKey]); + } + + // Reset ID field - no forced refresh + this.metricForm.get('metricId')?.setValue(''); + } else if (selectedKey === '' || selectedKey === null) { + // Clear IDs if key is cleared + this.metricIdOptions$.next([]); + this.metricForm.get('metricId')?.setValue(''); + } + // Don't do anything for string values - user is typing + } + + displayFn = (option?: any): string => { + return option?.key || option || ''; + }; + + private extractKey(value: any): string { + return typeof value === 'object' ? value?.key || '' : value || ''; + } + + private _filter(value: any, options: any[]): any[] { + const filterValue = this.extractKey(value).toLowerCase(); + return options.filter((option) => option.key?.toLowerCase().includes(filterValue)); + } + + onMetricTypeChange(): void { + // Clear everything and repopulate + this.currentSelectedClass = null; + this.currentSelectedKey = null; + this.metricDataType = null; + + // 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(); + } + + onMetricIdChange(metricId: any): void { + if (metricId) { + this.detectMetricDataType(metricId); + this.updateStatisticOptions(); + this.updateFormVisibility(); + this.updateFormValidators(); + } else { + // Clear everything when metric ID is cleared + this.metricDataType = null; + this.hideStatisticDropdowns(); + this.resetStatisticFields(); + this.updateFormValidators(); + } + } + + detectMetricDataType(metricId: any): 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: any): any { + if (typeof metricId === 'object' && metricId.metadata) { + return metricId; + } + + if (typeof metricId === 'string') { + const currentOptions = this.metricIdOptions$.getValue(); + return currentOptions.find((metric) => metric.key === metricId); + } + + return null; + } + + private setMetricDataType(dataType: IMetricMetaData, selectedMetric?: any): void { + this.metricDataType = dataType; + + if (dataType === IMetricMetaData.CATEGORICAL) { + this.allowableDataKeys = selectedMetric?.allowedData ? [...selectedMetric.allowedData] : []; + + // Set default comparison if not already set + if (!this.metricForm.get('comparison')?.value) { + this.metricForm.get('comparison')?.setValue('='); + } + } else { + this.allowableDataKeys = []; + } + } + + private detectMetricTypeByHeuristic(metricId: any): 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.metricForm.get('metricType')?.value; + const hasMetricId = !!this.metricForm.get('metricId')?.value; + + // Base visibility for metric type + this.showMetricClass = metricType === METRIC_TYPE.REPEATABLE; + this.showMetricKey = metricType === METRIC_TYPE.REPEATABLE; + + // Statistics only show when metric ID is selected + if (hasMetricId && 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 + if (this.isGlobalMetricDisabled && this.metricForm.get('metricType')?.value === 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() + } + + 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.metricForm.get('metricType')?.value; + + // Update validators based on metric type + if (metricType === METRIC_TYPE.REPEATABLE) { + this.metricForm.get('metricClass')?.setValidators([Validators.required]); + this.metricForm.get('metricKey')?.setValidators([Validators.required]); + 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 + Object.keys(this.metricForm.controls).forEach((key) => { + this.metricForm.get(key)?.updateValueAndValidity({ emitEvent: false }); + }); + } + + listenForIsInitialFormValueChanged(): void { + this.isInitialFormValueChanged$ = this.metricForm.valueChanges.pipe( + startWith(this.metricForm.value), + map(() => { + const currentWithKeys = { + ...this.metricForm.value, + allowableDataKeys: this.allowableDataKeys, + }; + return !isEqual(currentWithKeys, this.initialFormValues$.value); + }) + ); + } + + listenForPrimaryButtonDisabled(): void { + this.isPrimaryButtonDisabled$ = this.isLoadingUpsertMetric$.pipe( + combineLatestWith(this.isInitialFormValueChanged$), + map( + ([isLoading, isInitialFormValueChanged]) => + isLoading || + this.metricForm.invalid || + (!isInitialFormValueChanged && this.config.params.action === UPSERT_EXPERIMENT_ACTION.EDIT) + ) + ); + } + + onPrimaryActionBtnClicked(): void { + if (this.metricForm.valid) { + this.sendUpsertMetricRequest(); + } else { + CommonFormHelpersService.triggerTouchedToDisplayErrors(this.metricForm); + } + } + + sendUpsertMetricRequest(): void { + const formValue = this.metricForm.value; + const metricData = this.prepareMetricDataForBackend(formValue); + + // Get current experiment and call helper service + this.experimentService.selectedExperiment$.pipe(take(1)).subscribe((experiment: Experiment) => { + if (!experiment) { + console.error('No experiment selected'); + return; + } + + if (this.config.params.action === UPSERT_EXPERIMENT_ACTION.ADD) { + this.metricHelperService.addMetric(experiment, metricData); + } else { + const sourceQuery = this.config.params.sourceQuery; + if (!sourceQuery) { + console.error('No source query for edit action'); + return; + } + + this.metricHelperService.updateMetric(experiment, sourceQuery, metricData); + } + + this.closeModal(); + }); + } + + private prepareMetricDataForBackend(formValue: any): ExperimentQueryDTO { + const { metricType, metricClass, metricKey: metricKeyValue, metricId } = formValue; + + // Prepare metric key based on type + const metricKey = + metricType === METRIC_TYPE.GLOBAL + ? this.extractKey(metricId) + : `${this.extractKey(metricClass)}${METRICS_JOIN_TEXT}${this.extractKey( + metricKeyValue + )}${METRICS_JOIN_TEXT}${this.extractKey(metricId)}`; + + // Prepare query object + const queryObj: ExperimentQueryDTO = { + name: formValue.displayName, + query: { + operationType: formValue.aggregateStatistic, + // Add comparison for categorical metrics + ...(this.metricDataType === IMetricMetaData.CATEGORICAL && + formValue.comparison && + formValue.compareValue && { + compareFn: formValue.comparison, + compareValue: formValue.compareValue, + }), + }, + metric: { + key: metricKey, + }, + repeatedMeasure: + metricType === METRIC_TYPE.REPEATABLE ? formValue.individualStatistic : REPEATED_MEASURE.mostRecent, + }; + + 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.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..1d96def72d 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 @@ -12,14 +12,18 @@ import { 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', @@ -63,15 +67,24 @@ export class ExperimentMetricsSectionCardComponent implements OnInit { }, ]; - constructor(private experimentService: ExperimentService, private authService: AuthService) {} + constructor( + private experimentService: ExperimentService, + private metricHelperService: MetricHelperService, + private authService: AuthService, + private 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'); + // Get experiment ID from selected experiment + this.selectedExperiment$.pipe(take(1)).subscribe((experiment: Experiment) => { + if (experiment?.id) { + this.dialogService.openAddMetricModal(experiment.id); + } + }); } onMenuButtonItemClick(event: string, experiment: Experiment): void { @@ -99,20 +112,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 c0aa933bbe..6e8c109fb5 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 @@ -36,10 +36,12 @@ import { CommonImportModalComponent } from '../../shared-standalone-component-li import { DeleteSegmentModalComponent } from '../../features/dashboard/segments/modals/delete-segment-modal/delete-segment-modal.component'; 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 { UpsertMetricModalComponent } from '../../features/dashboard/experiments/modals/upsert-metric-modal/upsert-metric-modal.component'; import { UPSERT_EXPERIMENT_ACTION, ExperimentDecisionPoint, ExperimentCondition, + ExperimentQueryDTO, } from '../../core/experiments/store/experiments.model'; import { ConditionWeightUpdate, @@ -64,6 +66,12 @@ export interface UpsertDecisionPointModalParams { context: string; } +export interface UpsertMetricModalParams { + sourceQuery: ExperimentQueryDTO | null; + action: UPSERT_EXPERIMENT_ACTION; + experimentId: string; +} + @Injectable({ providedIn: 'root', }) @@ -147,6 +155,46 @@ export class DialogService { return this.dialog.open(UpsertDecisionPointModalComponent, 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 = { @@ -602,6 +650,20 @@ export class DialogService { return this.openSimpleCommonConfirmationModal(deleteDecisionPointModalConfig, 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 f7c594ce42..54314495a5 100644 --- a/frontend/projects/upgrade/src/assets/i18n/en.json +++ b/frontend/projects/upgrade/src/assets/i18n/en.json @@ -440,6 +440,15 @@ "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-key-hint.text": "Specifies the specific instance of the metric class being measured (e.g., problem ID).", + "experiments.upsert-metric-modal.metric-id-hint.text": "Specifies the data type (continuous or categorical) and what data to measure.", + "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", @@ -702,4 +711,4 @@ "monitor.metric-repeated-measure.text": "Repeated measure treatment", "monitor.monitored-metrics.text": "Monitored Metrics", "common-import-modal.incompatible-import-warning.text": "*Incompatible files will not be imported" -} \ No newline at end of file +} diff --git a/types/src/Experiment/enums.ts b/types/src/Experiment/enums.ts index acefd15539..7531053ac9 100644 --- a/types/src/Experiment/enums.ts +++ b/types/src/Experiment/enums.ts @@ -185,6 +185,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/index.ts b/types/src/index.ts index c87e363bad..59fec415a6 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,