diff --git a/frontend/projects/upgrade/src/app/core/experiments/decision-point-helper.service.ts b/frontend/projects/upgrade/src/app/core/experiments/decision-point-helper.service.ts new file mode 100644 index 0000000000..e70b415516 --- /dev/null +++ b/frontend/projects/upgrade/src/app/core/experiments/decision-point-helper.service.ts @@ -0,0 +1,90 @@ +import { Injectable } from '@angular/core'; +import { v4 as uuidv4 } from 'uuid'; + +import { ExperimentService } from './experiments.service'; +import { + Experiment, + ExperimentDecisionPoint, + UpdateExperimentDecisionPointsRequest, + DecisionPointFormData, +} from './store/experiments.model'; + +@Injectable({ + providedIn: 'root', +}) +export class DecisionPointHelperService { + constructor(private experimentService: ExperimentService) {} + + /** + * Add a new decision point to an experiment + */ + addDecisionPoint(experiment: Experiment, decisionPointData: DecisionPointFormData): void { + const currentDecisionPoints = [...(experiment.partitions || [])]; + const newDecisionPoint = { + id: uuidv4(), + site: decisionPointData.site, + target: decisionPointData.target, + description: '', + order: currentDecisionPoints.length + 1, + excludeIfReached: decisionPointData.excludeIfReached, + }; + + const updatedDecisionPoints = [...currentDecisionPoints, newDecisionPoint] as ExperimentDecisionPoint[]; + + this.updateExperimentDecisionPoints(experiment, updatedDecisionPoints); + } + + /** + * Update an existing decision point in an experiment + */ + updateDecisionPoint( + experiment: Experiment, + sourceDecisionPoint: ExperimentDecisionPoint, + decisionPointData: DecisionPointFormData + ): void { + const currentDecisionPoints = [...(experiment.partitions || [])]; + const updatedDecisionPoints = currentDecisionPoints.map((dp) => + dp.id === sourceDecisionPoint.id + ? { + ...dp, + site: decisionPointData.site, + target: decisionPointData.target, + excludeIfReached: decisionPointData.excludeIfReached, + } + : dp + ); + + this.updateExperimentDecisionPoints(experiment, updatedDecisionPoints); + } + + /** + * Delete a decision point from an experiment + */ + deleteDecisionPoint(experiment: Experiment, decisionPointToDelete: ExperimentDecisionPoint): void { + const currentDecisionPoints = [...(experiment.partitions || [])]; + const updatedDecisionPoints = currentDecisionPoints.filter((dp) => dp.id !== decisionPointToDelete.id); + + // Reorder the remaining decision points + const reorderedDecisionPoints = updatedDecisionPoints.map((dp, index) => ({ + ...dp, + order: index + 1, + })); + + this.updateExperimentDecisionPoints(experiment, reorderedDecisionPoints); + } + + /** + * Common method to update experiment decision points + */ + private updateExperimentDecisionPoints( + experiment: Experiment, + updatedDecisionPoints: ExperimentDecisionPoint[] + ): void { + const updateRequest: UpdateExperimentDecisionPointsRequest = { + experiment, + decisionPoints: updatedDecisionPoints, + }; + + this.experimentService.updateExperimentDecisionPoints(updateRequest); + } +} 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 bf82bc953b..592a5771c9 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 @@ -4,6 +4,7 @@ import { ExperimentStateInfo, ExperimentPaginationParams, UpdateExperimentFilterModeRequest, + UpdateExperimentDecisionPointsRequest, ExperimentSegmentListResponse, } from './store/experiments.model'; import { HttpClient, HttpParams } from '@angular/common/http'; @@ -156,4 +157,12 @@ export class ExperimentDataService { }; return this.updateExperiment(updatedExperiment); } + + updateExperimentDecisionPoints(params: UpdateExperimentDecisionPointsRequest): Observable { + const updatedExperiment = { + ...params.experiment, + partitions: params.decisionPoints, + }; + 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 0027ca1c7e..e3da2c5808 100644 --- a/frontend/projects/upgrade/src/app/core/experiments/experiments.service.ts +++ b/frontend/projects/upgrade/src/app/core/experiments/experiments.service.ts @@ -13,6 +13,7 @@ import { EXPERIMENT_STATE, AddExperimentRequest, UpdateExperimentFilterModeRequest, + UpdateExperimentDecisionPointsRequest, } from './store/experiments.model'; import { Store, select } from '@ngrx/store'; import { @@ -192,6 +193,12 @@ export class ExperimentService { this.store$.dispatch(experimentAction.actionUpdateExperimentFilterMode({ updateExperimentFilterModeRequest })); } + updateExperimentDecisionPoints(updateExperimentDecisionPointsRequest: UpdateExperimentDecisionPointsRequest) { + this.store$.dispatch( + experimentAction.actionUpdateExperimentDecisionPoints({ updateExperimentDecisionPointsRequest }) + ); + } + fetchContextMetaData() { this.store$.dispatch(experimentAction.actionFetchContextMetaData({ isLoadingContextMetaData: true })); } 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 f5bce45f32..f094bc0a8b 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 @@ -12,6 +12,7 @@ import { IEnrollmentStatByDate, IContextMetaData, UpdateExperimentFilterModeRequest, + UpdateExperimentDecisionPointsRequest, ExperimentSegmentListResponse, } from './experiments.model'; import { ExperimentSegmentListRequest, ExperimentSegmentListDetails } from '../../segments/store/segments.model'; @@ -101,6 +102,20 @@ export const actionUpdateExperimentFilterModeFailure = createAction( '[Experiment] Update Experiment Filter Mode Failure' ); +export const actionUpdateExperimentDecisionPoints = createAction( + '[Experiment] Update Experiment Decision Points', + props<{ updateExperimentDecisionPointsRequest: UpdateExperimentDecisionPointsRequest }>() +); + +export const actionUpdateExperimentDecisionPointsSuccess = createAction( + '[Experiment] Update Experiment Decision Points Success', + props<{ experiment: Experiment }>() +); + +export const actionUpdateExperimentDecisionPointsFailure = createAction( + '[Experiment] Update Experiment Decision Points 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 462fd413b9..eaa28786de 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 @@ -216,6 +216,30 @@ export class ExperimentEffects { ) ); + updateExperimentDecisionPoints$ = createEffect(() => + this.actions$.pipe( + ofType(experimentAction.actionUpdateExperimentDecisionPoints), + switchMap((action) => { + return this.experimentDataService + .updateExperimentDecisionPoints(action.updateExperimentDecisionPointsRequest) + .pipe( + map((experiment) => { + this.notificationService.showSuccess( + this.translate.instant('experiments.decision-points.update-success.text') + ); + return experimentAction.actionUpdateExperimentDecisionPointsSuccess({ experiment }); + }), + catchError(() => { + this.notificationService.showError( + this.translate.instant('experiments.decision-points.update-error.text') + ); + return [experimentAction.actionUpdateExperimentDecisionPointsFailure()]; + }) + ); + }) + ) + ); + 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 908d8f7e01..8d7e43586b 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 @@ -334,6 +334,13 @@ export interface UpsertExperimentParams { action: UPSERT_EXPERIMENT_ACTION; } +export interface UpsertDecisionPointParams { + sourceDecisionPoint: ExperimentDecisionPoint; + context: string; + action: UPSERT_EXPERIMENT_ACTION; + experimentId: string; +} + export interface ExperimentFormData { name: string; description: string; @@ -348,6 +355,12 @@ export interface ExperimentFormData { tags: string[]; } +export interface DecisionPointFormData { + site: string; + target: string; + excludeIfReached: boolean; +} + // Base interfaces matching backend DTO structure export interface ExperimentConditionDTO { id: string; @@ -482,6 +495,11 @@ export interface UpdateExperimentFilterModeRequest { filterMode: FILTER_MODE; } +export interface UpdateExperimentDecisionPointsRequest { + experiment: Experiment; + decisionPoints: ExperimentDecisionPoint[]; +} + export const EXPERIMENT_ROOT_COLUMN_NAMES = { NAME: 'name', STATUS: 'state', 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 c4442d2a70..48b45921ae 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 @@ -98,6 +98,14 @@ const reducer = createReducer( on(experimentsAction.actionUpdateExperimentFilterModeSuccess, (state, { experiment }) => adapter.upsertOne(experiment, { ...state, isLoadingExperiment: false }) ), + on(experimentsAction.actionUpdateExperimentDecisionPoints, (state) => ({ ...state, isLoadingExperiment: true })), + on(experimentsAction.actionUpdateExperimentDecisionPointsSuccess, (state, { experiment }) => + adapter.upsertOne(experiment, { ...state, isLoadingExperiment: false }) + ), + on(experimentsAction.actionUpdateExperimentDecisionPointsFailure, (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-decision-point-modal/upsert-decision-point-modal.component.html b/frontend/projects/upgrade/src/app/features/dashboard/experiments/modals/upsert-decision-point-modal/upsert-decision-point-modal.component.html new file mode 100644 index 0000000000..7c8beb754d --- /dev/null +++ b/frontend/projects/upgrade/src/app/features/dashboard/experiments/modals/upsert-decision-point-modal/upsert-decision-point-modal.component.html @@ -0,0 +1,76 @@ + +
+ + + Site + + + + {{ site }} + + + + {{ 'experiments.upsert-decision-point-modal.site-hint.text' | translate }} + + Learn more + + + + + + Target + + + + {{ target }} + + + + {{ 'experiments.upsert-decision-point-modal.target-hint.text' | translate }} + + Learn more + + + + +
+ + Exclude If Reached + + {{ 'experiments.upsert-decision-point-modal.exclude-if-reached-hint.text' | translate }} + + Learn more + + + +
+ +
+
diff --git a/frontend/projects/upgrade/src/app/features/dashboard/experiments/modals/upsert-decision-point-modal/upsert-decision-point-modal.component.scss b/frontend/projects/upgrade/src/app/features/dashboard/experiments/modals/upsert-decision-point-modal/upsert-decision-point-modal.component.scss new file mode 100644 index 0000000000..bee16bc5c1 --- /dev/null +++ b/frontend/projects/upgrade/src/app/features/dashboard/experiments/modals/upsert-decision-point-modal/upsert-decision-point-modal.component.scss @@ -0,0 +1,7 @@ +.exclude-if-reached-container { + margin-bottom: 16px; + + .checkbox-label { + margin-left: 0; + } +} diff --git a/frontend/projects/upgrade/src/app/features/dashboard/experiments/modals/upsert-decision-point-modal/upsert-decision-point-modal.component.ts b/frontend/projects/upgrade/src/app/features/dashboard/experiments/modals/upsert-decision-point-modal/upsert-decision-point-modal.component.ts new file mode 100644 index 0000000000..af414b8aec --- /dev/null +++ b/frontend/projects/upgrade/src/app/features/dashboard/experiments/modals/upsert-decision-point-modal/upsert-decision-point-modal.component.ts @@ -0,0 +1,214 @@ +import { ChangeDetectionStrategy, Component, Inject, OnDestroy, OnInit } from '@angular/core'; +import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; +import { FormBuilder, FormGroup, Validators, ReactiveFormsModule } from '@angular/forms'; +import { CommonModule } from '@angular/common'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatInputModule } from '@angular/material/input'; +import { MatCheckboxModule } from '@angular/material/checkbox'; +import { MatAutocompleteModule } from '@angular/material/autocomplete'; +import { TranslateModule } from '@ngx-translate/core'; +import { BehaviorSubject, Observable, Subscription, combineLatestWith, map, startWith, take } from 'rxjs'; +import isEqual from 'lodash.isequal'; + +import { CommonModalComponent } from '../../../../../shared-standalone-component-lib/components'; +import { ExperimentService } from '../../../../../core/experiments/experiments.service'; +import { DecisionPointHelperService } from '../../../../../core/experiments/decision-point-helper.service'; +import { CommonFormHelpersService } from '../../../../../shared/services/common-form-helpers.service'; +import { CommonModalConfig } from '../../../../../shared-standalone-component-lib/components/common-modal/common-modal.types'; +import { + UPSERT_EXPERIMENT_ACTION, + UpsertDecisionPointParams, + DecisionPointFormData, + IContextMetaData, + ExperimentDecisionPoint, + Experiment, +} from '../../../../../core/experiments/store/experiments.model'; + +@Component({ + selector: 'upsert-decision-point-modal', + imports: [ + CommonModalComponent, + MatFormFieldModule, + MatInputModule, + MatCheckboxModule, + MatAutocompleteModule, + CommonModule, + ReactiveFormsModule, + TranslateModule, + ], + templateUrl: './upsert-decision-point-modal.component.html', + styleUrl: './upsert-decision-point-modal.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class UpsertDecisionPointModalComponent implements OnInit, OnDestroy { + isLoadingUpsertDecisionPoint$ = this.experimentService.isLoadingExperiment$; + contextMetaData$ = this.experimentService.contextMetaData$; + + subscriptions = new Subscription(); + isPrimaryButtonDisabled$: Observable; + isInitialFormValueChanged$: Observable; + + initialFormValues$ = new BehaviorSubject(null); + + // Filtered options for autocomplete + filteredSites$: Observable; + filteredTargets$: Observable; + + decisionPointForm: FormGroup; + currentContext: string; + + constructor( + @Inject(MAT_DIALOG_DATA) + public config: CommonModalConfig, + private formBuilder: FormBuilder, + private experimentService: ExperimentService, + private decisionPointHelperService: DecisionPointHelperService, + public dialogRef: MatDialogRef + ) {} + + ngOnInit(): void { + this.experimentService.fetchContextMetaData(); + + if (!this.config.params.context) { + throw new Error('Context parameter is required for decision point modal'); + } + + this.currentContext = this.config.params.context; + this.createDecisionPointForm(); + this.setupAutocompleteFilters(); + + // Add listeners AFTER form is fully set up + this.listenForIsInitialFormValueChanged(); + this.listenForPrimaryButtonDisabled(); + } + + ngOnDestroy(): void { + this.subscriptions.unsubscribe(); + } + + createDecisionPointForm(): void { + const { sourceDecisionPoint, action } = this.config.params; + const initialValues = this.deriveInitialFormValues(sourceDecisionPoint, action); + + this.decisionPointForm = this.formBuilder.group({ + site: [initialValues.site, [Validators.required]], + target: [initialValues.target, [Validators.required]], + excludeIfReached: [initialValues.excludeIfReached], + }); + + this.initialFormValues$.next(this.decisionPointForm.value); + } + + deriveInitialFormValues( + sourceDecisionPoint: ExperimentDecisionPoint, + action: UPSERT_EXPERIMENT_ACTION + ): DecisionPointFormData { + const site = action === UPSERT_EXPERIMENT_ACTION.EDIT && sourceDecisionPoint?.site ? sourceDecisionPoint.site : ''; + const target = + action === UPSERT_EXPERIMENT_ACTION.EDIT && sourceDecisionPoint?.target ? sourceDecisionPoint.target : ''; + const excludeIfReached = sourceDecisionPoint?.excludeIfReached || false; + + return { site, target, excludeIfReached }; + } + + setupAutocompleteFilters(): void { + this.filteredSites$ = this.contextMetaData$.pipe( + combineLatestWith(this.decisionPointForm.get('site').valueChanges.pipe(startWith(''))), + map(([metaData, value]) => this.filterSitesAndTargets(metaData, value || '', 'EXP_POINTS')) + ); + + this.filteredTargets$ = this.contextMetaData$.pipe( + combineLatestWith(this.decisionPointForm.get('target').valueChanges.pipe(startWith(''))), + map(([metaData, value]) => this.filterSitesAndTargets(metaData, value || '', 'EXP_IDS')) + ); + } + + private filterSitesAndTargets(metaData: IContextMetaData, value: string, field: 'EXP_POINTS' | 'EXP_IDS'): string[] { + const filterValue = value ? value.toLowerCase() : ''; + + if (!metaData || !this.currentContext || !metaData.contextMetadata) { + return []; + } + + const contextData = metaData.contextMetadata[this.currentContext]; + if (!contextData || !contextData[field]) { + return []; + } + + return contextData[field].filter((option) => option.toLowerCase().startsWith(filterValue)); + } + + listenForIsInitialFormValueChanged(): void { + this.isInitialFormValueChanged$ = this.decisionPointForm.valueChanges.pipe( + startWith(this.decisionPointForm.value), + map(() => !isEqual(this.decisionPointForm.value, this.initialFormValues$.value)) + ); + this.subscriptions.add(this.isInitialFormValueChanged$.subscribe()); + } + + listenForPrimaryButtonDisabled(): void { + this.isPrimaryButtonDisabled$ = this.isLoadingUpsertDecisionPoint$.pipe( + combineLatestWith(this.isInitialFormValueChanged$), + map( + ([isLoading, isInitialFormValueChanged]) => + isLoading || + this.decisionPointForm.invalid || + (!isInitialFormValueChanged && this.config.params.action !== UPSERT_EXPERIMENT_ACTION.ADD) + ) + ); + this.subscriptions.add(this.isPrimaryButtonDisabled$.subscribe()); + } + + onPrimaryActionBtnClicked(): void { + if (this.decisionPointForm.valid) { + this.sendUpsertDecisionPointRequest(); + } else { + CommonFormHelpersService.triggerTouchedToDisplayErrors(this.decisionPointForm); + } + } + + sendUpsertDecisionPointRequest(): void { + const formData = this.decisionPointForm.value; + const decisionPointData: DecisionPointFormData = { + site: formData.site?.trim() || '', + target: formData.target?.trim() || '', + excludeIfReached: formData.excludeIfReached, + }; + + // Validate trimmed values are not empty + if (!decisionPointData.site || !decisionPointData.target) { + CommonFormHelpersService.triggerTouchedToDisplayErrors(this.decisionPointForm); + return; + } + + // 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.decisionPointHelperService.addDecisionPoint(experiment, decisionPointData); + } else { + const sourceDecisionPoint = this.config.params.sourceDecisionPoint; + if (!sourceDecisionPoint) { + console.error('No source decision point for edit action'); + return; + } + + this.decisionPointHelperService.updateDecisionPoint(experiment, sourceDecisionPoint, decisionPointData); + } + + this.closeModal(); + }); + } + + get UPSERT_EXPERIMENT_ACTION() { + return UPSERT_EXPERIMENT_ACTION; + } + + 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-decision-points-section-card/experiment-decision-points-section-card.component.html b/frontend/projects/upgrade/src/app/features/dashboard/experiments/pages/experiment-details-page/experiment-details-page-content/experiment-decision-points-section-card/experiment-decision-points-section-card.component.html index 638937acbb..2c0d3f4f45 100644 --- a/frontend/projects/upgrade/src/app/features/dashboard/experiments/pages/experiment-details-page/experiment-details-page-content/experiment-decision-points-section-card/experiment-decision-points-section-card.component.html +++ b/frontend/projects/upgrade/src/app/features/dashboard/experiments/pages/experiment-details-page/experiment-details-page-content/experiment-decision-points-section-card/experiment-decision-points-section-card.component.html @@ -15,7 +15,7 @@ [showMenuButton]="(permissions$ | async)?.experiments.update" [menuButtonItems]="menuButtonItems" [isSectionCardExpanded]="isSectionCardExpanded" - (primaryButtonClick)="onAddDecisionPointClick(experiment.context[0], experiment.id)" + (primaryButtonClick)="onAddDecisionPointClick(experiment.id, experiment.context[0])" (menuButtonItemClick)="onMenuButtonItemClick($event, experiment)" (sectionCardExpandChange)="onSectionCardExpandChange($event)" > @@ -28,6 +28,6 @@ [decisionPoints]="experiment.partitions" [isLoading$]="experimentService.isLoadingSelectedExperiment$" [actionsDisabled]="!(permissions$ | async)?.experiments.update" - (rowAction)="onRowAction($event, experiment.id)" + (rowAction)="onRowAction($event, experiment.id, experiment.context[0])" > diff --git a/frontend/projects/upgrade/src/app/features/dashboard/experiments/pages/experiment-details-page/experiment-details-page-content/experiment-decision-points-section-card/experiment-decision-points-section-card.component.ts b/frontend/projects/upgrade/src/app/features/dashboard/experiments/pages/experiment-details-page/experiment-details-page-content/experiment-decision-points-section-card/experiment-decision-points-section-card.component.ts index 426aaf6193..8419806cbb 100644 --- a/frontend/projects/upgrade/src/app/features/dashboard/experiments/pages/experiment-details-page/experiment-details-page-content/experiment-decision-points-section-card/experiment-decision-points-section-card.component.ts +++ b/frontend/projects/upgrade/src/app/features/dashboard/experiments/pages/experiment-details-page/experiment-details-page-content/experiment-decision-points-section-card/experiment-decision-points-section-card.component.ts @@ -9,16 +9,18 @@ import { TranslateModule } from '@ngx-translate/core'; import { IMenuButtonItem } from 'upgrade_types'; import { ExperimentDecisionPointsTableComponent } from './experiment-decision-points-table/experiment-decision-points-table.component'; import { ExperimentService } from '../../../../../../../core/experiments/experiments.service'; -import { Observable } from 'rxjs'; +import { DecisionPointHelperService } from '../../../../../../../core/experiments/decision-point-helper.service'; +import { Observable, take } from 'rxjs'; import { - Experiment, EXPERIMENT_BUTTON_ACTION, EXPERIMENT_ROW_ACTION, ExperimentDecisionPoint, ExperimentDecisionPointRowActionEvent, + Experiment, } 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-decision-points-section-card', @@ -53,18 +55,22 @@ export class ExperimentDecisionPointsSectionCardComponent implements OnInit { }, ]; - constructor(public experimentService: ExperimentService, private authService: AuthService) {} + constructor( + public experimentService: ExperimentService, + private authService: AuthService, + private dialogService: DialogService, + private decisionPointHelperService: DecisionPointHelperService + ) {} ngOnInit() { this.permissions$ = this.authService.userPermissions$; } - onAddDecisionPointClick(appContext: string, experimentId: string): void { - // TODO: Implement add decision point functionality when dialog service is available - console.log('Add decision point'); + onAddDecisionPointClick(experimentId: string, appContext: string): void { + this.dialogService.openAddDecisionPointModal(experimentId, appContext); } - onMenuButtonItemClick(event: string, experiment: Experiment): void { + onMenuButtonItemClick(event: string): void { switch (event) { case EXPERIMENT_BUTTON_ACTION.IMPORT_DECISION_POINT: // TODO: Implement import functionality when dialog service is available @@ -84,10 +90,10 @@ export class ExperimentDecisionPointsSectionCardComponent implements OnInit { } // Decision point row action events - onRowAction(event: ExperimentDecisionPointRowActionEvent, experimentId: string): void { + onRowAction(event: ExperimentDecisionPointRowActionEvent, experimentId: string, context: string): void { switch (event.action) { case EXPERIMENT_ROW_ACTION.EDIT: - this.onEditDecisionPoint(event.decisionPoint, experimentId); + this.onEditDecisionPoint(event.decisionPoint, experimentId, context); break; case EXPERIMENT_ROW_ACTION.DELETE: this.onDeleteDecisionPoint(event.decisionPoint); @@ -97,13 +103,24 @@ export class ExperimentDecisionPointsSectionCardComponent implements OnInit { } } - onEditDecisionPoint(decisionPoint: ExperimentDecisionPoint, experimentId: string): void { - // TODO: Implement edit functionality when dialog service is available - console.log('Edit decision point'); + onEditDecisionPoint(decisionPoint: ExperimentDecisionPoint, experimentId: string, context: string): void { + this.dialogService.openEditDecisionPointModal(decisionPoint, experimentId, context); } onDeleteDecisionPoint(decisionPoint: ExperimentDecisionPoint): void { - // TODO: Implement delete functionality when dialog service is available - console.log('Delete decision point'); + const decisionPointDisplayName = `${decisionPoint.site}; ${decisionPoint.target}`; + + this.dialogService + .openDeleteDecisionPointModal(decisionPointDisplayName) + .afterClosed() + .subscribe((confirmClicked) => { + if (confirmClicked) { + this.selectedExperiment$.pipe(take(1)).subscribe((experiment: Experiment) => { + if (experiment) { + this.decisionPointHelperService.deleteDecisionPoint(experiment, decisionPoint); + } + }); + } + }); } } 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 637e5f087c..c0aa933bbe 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 @@ -35,7 +35,12 @@ import { import { CommonImportModalComponent } from '../../shared-standalone-component-lib/components/common-import-modal/common-import-modal.component'; 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 { ExperimentCondition, UPSERT_EXPERIMENT_ACTION } from '../../core/experiments/store/experiments.model'; +import { UpsertDecisionPointModalComponent } from '../../features/dashboard/experiments/modals/upsert-decision-point-modal/upsert-decision-point-modal.component'; +import { + UPSERT_EXPERIMENT_ACTION, + ExperimentDecisionPoint, + ExperimentCondition, +} from '../../core/experiments/store/experiments.model'; import { ConditionWeightUpdate, EditConditionWeightsModalComponent, @@ -52,6 +57,13 @@ export interface ImportModalParams { listType?: LIST_FILTER_MODE; // for feature flag list import } +export interface UpsertDecisionPointModalParams { + sourceDecisionPoint: ExperimentDecisionPoint | null; + action: UPSERT_EXPERIMENT_ACTION; + experimentId: string; + context: string; +} + @Injectable({ providedIn: 'root', }) @@ -93,6 +105,48 @@ export class DialogService { return this.dialog.open(UpsertExperimentModalComponent, config); } + openAddDecisionPointModal(experimentId: string, context: string) { + const commonModalConfig: CommonModalConfig = { + title: 'Add Decision Point', + primaryActionBtnLabel: 'Create', + primaryActionBtnColor: 'primary', + cancelBtnLabel: 'Cancel', + params: { + sourceDecisionPoint: null, + action: UPSERT_EXPERIMENT_ACTION.ADD, + experimentId, + context, + }, + }; + return this.openUpsertDecisionPointModal(commonModalConfig); + } + + openEditDecisionPointModal(sourceDecisionPoint: ExperimentDecisionPoint, experimentId: string, context: string) { + const commonModalConfig: CommonModalConfig = { + title: 'Edit Decision Point', + primaryActionBtnLabel: 'Save', + primaryActionBtnColor: 'primary', + cancelBtnLabel: 'Cancel', + params: { + sourceDecisionPoint: { ...sourceDecisionPoint }, + action: UPSERT_EXPERIMENT_ACTION.EDIT, + experimentId, + context, + }, + }; + return this.openUpsertDecisionPointModal(commonModalConfig); + } + + openUpsertDecisionPointModal(commonModalConfig: CommonModalConfig) { + const config: MatDialogConfig = { + data: commonModalConfig, + width: ModalSize.STANDARD, + autoFocus: false, + disableClose: true, + }; + return this.dialog.open(UpsertDecisionPointModalComponent, config); + } + // feature flag modal ---------------------------------------- // openAddFeatureFlagModal() { const commonModalConfig: CommonModalConfig = { @@ -534,6 +588,20 @@ export class DialogService { return this.dialog.open(DeleteFeatureFlagModalComponent, config); } + openDeleteDecisionPointModal(decisionPointName: string) { + const deleteDecisionPointModalConfig: CommonModalConfig = { + title: 'Delete Decision Point', + primaryActionBtnLabel: 'Delete', + primaryActionBtnColor: 'warn', + cancelBtnLabel: 'Cancel', + params: { + message: `Are you sure you want to delete decision point "${decisionPointName}"?`, + }, + }; + + return this.openSimpleCommonConfirmationModal(deleteDecisionPointModalConfig, 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 f636554308..f7c594ce42 100644 --- a/frontend/projects/upgrade/src/assets/i18n/en.json +++ b/frontend/projects/upgrade/src/assets/i18n/en.json @@ -401,6 +401,11 @@ "experiments.details.decision-points.exclude-if-reached.text": "Exclude If Reached", "experiments.details.decision-points.actions.text": "Actions", "experiments.details.decision-points.card.no-data-row.text": "No decision points defined. Experiments require at least one decision point.", + "experiments.upsert-decision-point-modal.site-hint.text": "The site indicates a place in the code where the condition will be assigned.", + "experiments.upsert-decision-point-modal.target-hint.text": "The target indicates the app component that will undergo the experiment.", + "experiments.upsert-decision-point-modal.exclude-if-reached-hint.text": "Exclude students who visited the decision point while the experiment is inactive.", + "experiments.decision-points.update-success.text": "Decision point updated successfully.", + "experiments.decision-points.update-error.text": "Failed to update decision point. Please try again.", "experiments.details.conditions.card.title.text": "Conditions", "experiments.details.conditions.card.subtitle.text": "Define conditions for this experiment.", "experiments.details.add-condition.button.text": "Add Condition",