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 bf9e34aed1..0027ca1c7e 100644 --- a/frontend/projects/upgrade/src/app/core/experiments/experiments.service.ts +++ b/frontend/projects/upgrade/src/app/core/experiments/experiments.service.ts @@ -52,6 +52,7 @@ import { map, filter, tap } from 'rxjs/operators'; import { LocalStorageService } from '../local-storage/local-storage.service'; import { ENV, Environment } from '../../../environments/environment-types'; import { ExperimentSegmentListRequest } from '../segments/store/segments.model'; +import { ConditionWeightUpdate } from '../../features/dashboard/experiments/modals/edit-condition-weights-modal/edit-condition-weights-modal.component'; @Injectable() export class ExperimentService { @@ -314,4 +315,29 @@ export class ExperimentService { deleteExperimentExclusionPrivateSegmentList(segmentId: string) { this.store$.dispatch(experimentAction.actionDeleteExperimentExclusionList({ segmentId })); } + + updateExperimentConditionWeights(experiment: ExperimentVM, weightUpdates: ConditionWeightUpdate[]): void { + // Create updated experiment with new condition weights + const updatedExperiment: ExperimentVM = { + ...experiment, + conditions: experiment.conditions.map((condition) => { + const weightUpdate = weightUpdates.find((update) => update.conditionId === condition.id); + + return weightUpdate + ? { + ...condition, + assignmentWeight: weightUpdate.assignmentWeight, + } + : condition; + }), + }; + + // Dispatch the update action + this.store$.dispatch( + experimentAction.actionUpsertExperiment({ + experiment: updatedExperiment, + actionType: UpsertExperimentType.UPDATE_EXPERIMENT, + }) + ); + } } 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 09cbbf658f..908d8f7e01 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 @@ -80,11 +80,6 @@ export enum NewExperimentPaths { POST_EXPERIMENT_RULE = 'Post Experiment Rule', } -export enum ExperimentDesignTypes { - SIMPLE = 'Simple', - FACTORIAL = 'Factorial', -} - export enum OverviewFormWarningStatus { NO_WARNING = 'no warning', CONTEXT_CHANGED = 'context changed', @@ -343,7 +338,7 @@ export interface ExperimentFormData { name: string; description: string; appContext: string; - experimentType: ExperimentDesignTypes; + experimentType: EXPERIMENT_TYPE; unitOfAssignment: ASSIGNMENT_UNIT; consistencyRule: CONSISTENCY_RULE; conditionOrder?: CONDITION_ORDER; @@ -430,7 +425,7 @@ export interface DraftExperimentRequest { name: string; description?: string; context: string[]; - type: ExperimentDesignTypes; + type: EXPERIMENT_TYPE; assignmentUnit: ASSIGNMENT_UNIT; state: EXPERIMENT_STATE; filterMode: FILTER_MODE; diff --git a/frontend/projects/upgrade/src/app/features/dashboard/experiments/modals/edit-condition-weights-modal/edit-condition-weights-modal.component.html b/frontend/projects/upgrade/src/app/features/dashboard/experiments/modals/edit-condition-weights-modal/edit-condition-weights-modal.component.html new file mode 100644 index 0000000000..11d3000098 --- /dev/null +++ b/frontend/projects/upgrade/src/app/features/dashboard/experiments/modals/edit-condition-weights-modal/edit-condition-weights-modal.component.html @@ -0,0 +1,122 @@ + +
+
+ + + +
+ + {{ method.name | titlecase }} + + + {{ method.description | translate }} + +
+
+
+
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ Condition + + {{ condition.conditionCode }} + + @if (getTotalWeightStatus()?.totalWeightInvalid) { +
+ {{ 'experiments.edit-condition-weights-modal.weights-sum-validation.text' | translate }} +
+ } + @if (getTotalWeightStatus()?.max || getTotalWeightStatus()?.min ) { +
+ {{ 'experiments.edit-condition-weights-modal.weights-range-validation.text' | translate }} +
+ } + @if (getTotalWeightStatus()?.invalidNumber) { +
+ {{ 'experiments.edit-condition-weights-modal.invalid-number-error.text' | translate }} +
+ } + @if (getTotalWeightStatus()?.tooManyDecimals) { +
+ {{ 'experiments.edit-condition-weights-modal.weights-decimal-validation.text' | translate }} +
+ } +
+ Weight (%) + + + + + + + + + {{ getCurrentTotal() | number:'1.2-2' }} + +
+ No conditions available +
+ +
+
+
\ No newline at end of file diff --git a/frontend/projects/upgrade/src/app/features/dashboard/experiments/modals/edit-condition-weights-modal/edit-condition-weights-modal.component.scss b/frontend/projects/upgrade/src/app/features/dashboard/experiments/modals/edit-condition-weights-modal/edit-condition-weights-modal.component.scss new file mode 100644 index 0000000000..3829cbf19f --- /dev/null +++ b/frontend/projects/upgrade/src/app/features/dashboard/experiments/modals/edit-condition-weights-modal/edit-condition-weights-modal.component.scss @@ -0,0 +1,161 @@ +.weight-input { + width: 90px; + + .mat-mdc-form-field-subscript-wrapper { + display: none; + } +} + +.table-container { + position: relative; + overflow: auto; + width: 100%; + + /* Add gap between header and body when no data exists */ + ::ng-deep .no-data tbody:before { + display: block; + line-height: 8px; + content: '\200C'; + } + .conditions-table { + /* Remove arrows/spinners from input type number */ + input[type="number"] { + -webkit-appearance: textfield; + -moz-appearance: textfield; + + &::-webkit-outer-spin-button, + &::-webkit-inner-spin-button { + -webkit-appearance: none; + margin: 0; + } + } + + ::ng-deep thead { + background-color: var(--zircon); + + tr.mat-mdc-header-row { + height: 48px; + border: 0; + + th { + padding-left: 0; + color: var(--darker-grey); + + &:first-child { + border-top-left-radius: 4px; + } + + &:last-child { + border-top-right-radius: 4px; + } + } + } + } + + ::ng-deep tbody { + tr.mat-mdc-row { + height: 56px; + + td { + min-width: 96px; + padding-left: 0; + color: var(--black-2); + } + } + + tr.mat-mdc-no-data-row { + td { + height: 48px; + text-align: center; + border: 1.5px dashed var(--light-grey-2); + color: var(--dark-grey); + } + } + } + + ::ng-deep tfoot { + tr.mat-mdc-footer-row.summary-footer-row { + height: 48px; + border-top: 2px solid var(--light-grey-2); + background-color: var(--light-grey-0); + + td.summary-row { + min-width: 96px; + padding-left: 0; + font-weight: 600; + + &.condition-column { + padding-left: 32px; + } + + &.weight-column { + text-align: right; + padding-right: 32px; + } + + .total-weight { + margin: 4px; + } + + .error-text { + color: #f44336; + font-weight: 500; + } + + } + } + } + + .condition-column { + width: 65%; + padding-left: 32px; + } + + .weight-column { + width: 35%; + text-align: right; + padding-right: 32px; + + .weight-input { + width: 90px; + flex-direction: row; + + input { + text-align: right; + } + + + .mat-mdc-form-field-subscript-wrapper { + display: none; + } + } + } + } +} + +.weight-method-section { + margin-bottom: 6px; + + .section-label { + display: block; + } + + mat-radio-group { + display: flex; + flex-direction: column; + gap: 6px; + } + + .radio-content { + display: flex; + flex-direction: column; + + .radio-description { + color: var(--dark-grey); + } + } + + .disabled-text { + opacity: 0.6; + } +} \ No newline at end of file diff --git a/frontend/projects/upgrade/src/app/features/dashboard/experiments/modals/edit-condition-weights-modal/edit-condition-weights-modal.component.spec.ts b/frontend/projects/upgrade/src/app/features/dashboard/experiments/modals/edit-condition-weights-modal/edit-condition-weights-modal.component.spec.ts new file mode 100644 index 0000000000..365142c929 --- /dev/null +++ b/frontend/projects/upgrade/src/app/features/dashboard/experiments/modals/edit-condition-weights-modal/edit-condition-weights-modal.component.spec.ts @@ -0,0 +1,499 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { ReactiveFormsModule } from '@angular/forms'; +import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; +import { MatTableModule } from '@angular/material/table'; +import { MatRadioModule } from '@angular/material/radio'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatInputModule } from '@angular/material/input'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { TranslateModule } from '@ngx-translate/core'; +import { CommonModule } from '@angular/common'; + +import { EditConditionWeightsModalComponent } from './edit-condition-weights-modal.component'; +import { CommonModalComponent } from '../../../../../shared-standalone-component-lib/components'; + +describe('EditConditionWeightsModalComponent', () => { + let component: EditConditionWeightsModalComponent; + let fixture: ComponentFixture; + let mockDialogRef: jest.Mocked>; + + const mockDialogData = { + title: 'Edit Condition Weights', + primaryActionBtnLabel: 'Update', + cancelBtnLabel: 'Cancel', + params: { + experimentWeightsArray: [ + { conditionId: '1', conditionCode: 'Control', assignmentWeight: 50 }, + { conditionId: '2', conditionCode: 'Treatment', assignmentWeight: 50 }, + ], + }, + }; + + const setupComponent = async (data = mockDialogData) => { + mockDialogRef = { + close: jest.fn(), + } as any; + + await TestBed.configureTestingModule({ + imports: [ + EditConditionWeightsModalComponent, + CommonModalComponent, + ReactiveFormsModule, + MatTableModule, + MatRadioModule, + MatFormFieldModule, + MatInputModule, + NoopAnimationsModule, + TranslateModule.forRoot(), + CommonModule, + ], + providers: [ + { provide: MAT_DIALOG_DATA, useValue: data }, + { provide: MatDialogRef, useValue: mockDialogRef }, + ], + }).compileComponents(); + + fixture = TestBed.createComponent(EditConditionWeightsModalComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + + return { component, fixture, mockDialogRef }; + }; + + beforeEach(async () => { + await setupComponent(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + describe('Form Initialization', () => { + it('should initialize form with correct structure', () => { + expect(component.conditionWeightForm).toBeDefined(); + expect(component.conditionWeightForm.get('weightingMethod')).toBeDefined(); + expect(component.conditionWeightForm.get('conditions')).toBeDefined(); + }); + + it('should initialize with equal weighting method for equal weights', () => { + expect(component.conditionWeightForm.get('weightingMethod')?.value).toBe('equal'); + }); + + it('should initialize with custom weighting method for unequal weights', async () => { + const unequalWeightsData = { + ...mockDialogData, + params: { + experimentWeightsArray: [ + { conditionId: '1', conditionCode: 'Control', assignmentWeight: 70 }, + { conditionId: '2', conditionCode: 'Treatment', assignmentWeight: 30 }, + ], + }, + }; + + const { component } = await setupComponent(unequalWeightsData); + expect(component.conditionWeightForm.get('weightingMethod')?.value).toBe('custom'); + }); + + it('should create form array with correct number of conditions', () => { + expect(component.conditionsFormArray.length).toBe(2); + }); + + it('should initialize conditions with provided data', () => { + const firstCondition = component.conditionsFormArray.at(0); + expect(firstCondition.get('conditionCode')?.value).toBe('Control'); + expect(firstCondition.get('assignmentWeight')?.value).toBe(50); + }); + + it('should initially disable weight inputs for equal method', () => { + component.conditionsFormArray.controls.forEach((control) => { + expect(control.get('assignmentWeight')?.disabled).toBe(true); + }); + }); + + it('should initially enable weight inputs for custom method', async () => { + const unequalWeightsData = { + ...mockDialogData, + params: { + experimentWeightsArray: [ + { conditionId: '1', conditionCode: 'Control', assignmentWeight: 70 }, + { conditionId: '2', conditionCode: 'Treatment', assignmentWeight: 30 }, + ], + }, + }; + + const { component } = await setupComponent(unequalWeightsData); + component.conditionsFormArray.controls.forEach((control) => { + expect(control.get('assignmentWeight')?.enabled).toBe(true); + }); + }); + }); + + describe('Initial Weighting Method Determination', () => { + it('should detect equal weights for 2 conditions with 50/50', async () => { + const { component } = await setupComponent(); + expect(component.conditionWeightForm.get('weightingMethod')?.value).toBe('equal'); + }); + + it('should detect equal weights for 3 conditions with 33.33/33.33/33.34', async () => { + const threeEqualConditions = { + ...mockDialogData, + params: { + experimentWeightsArray: [ + { conditionId: '1', conditionCode: 'A', assignmentWeight: 33.33 }, + { conditionId: '2', conditionCode: 'B', assignmentWeight: 33.33 }, + { conditionId: '3', conditionCode: 'C', assignmentWeight: 33.34 }, + ], + }, + }; + + const { component } = await setupComponent(threeEqualConditions); + expect(component.conditionWeightForm.get('weightingMethod')?.value).toBe('equal'); + }); + + it('should detect custom weights for unequal distribution', async () => { + const unequalWeights = { + ...mockDialogData, + params: { + experimentWeightsArray: [ + { conditionId: '1', conditionCode: 'Control', assignmentWeight: 60 }, + { conditionId: '2', conditionCode: 'Treatment', assignmentWeight: 40 }, + ], + }, + }; + + const { component } = await setupComponent(unequalWeights); + expect(component.conditionWeightForm.get('weightingMethod')?.value).toBe('custom'); + }); + + it('should detect custom weights when total does not equal 100', async () => { + const invalidTotalWeights = { + ...mockDialogData, + params: { + experimentWeightsArray: [ + { conditionId: '1', conditionCode: 'Control', assignmentWeight: 50 }, + { conditionId: '2', conditionCode: 'Treatment', assignmentWeight: 40 }, + ], + }, + }; + + const { component } = await setupComponent(invalidTotalWeights); + expect(component.conditionWeightForm.get('weightingMethod')?.value).toBe('custom'); + }); + + it('should handle single condition as equal when weight is 100%', async () => { + const singleCondition = { + ...mockDialogData, + params: { + experimentWeightsArray: [{ conditionId: '1', conditionCode: 'Control', assignmentWeight: 100 }], + }, + }; + + const { component } = await setupComponent(singleCondition); + expect(component.conditionWeightForm.get('weightingMethod')?.value).toBe('equal'); + }); + }); + + describe('Weighting Method Changes', () => { + it('should enable weight inputs and mark form as dirty when custom method is selected', () => { + component.conditionWeightForm.get('weightingMethod')?.setValue('custom'); + fixture.detectChanges(); + + component.conditionsFormArray.controls.forEach((control) => { + expect(control.get('assignmentWeight')?.enabled).toBe(true); + }); + expect(component.conditionWeightForm.dirty).toBe(true); + }); + + it('should distribute weights equally and mark form as dirty when equal method is selected', () => { + // First set to custom to see the change + component.conditionWeightForm.get('weightingMethod')?.setValue('custom'); + component.conditionWeightForm.markAsPristine(); // Reset dirty state + + component.conditionWeightForm.get('weightingMethod')?.setValue('equal'); + fixture.detectChanges(); + + const firstWeight = component.conditionsFormArray.at(0).get('assignmentWeight')?.value; + const secondWeight = component.conditionsFormArray.at(1).get('assignmentWeight')?.value; + + expect(firstWeight + secondWeight).toBe(100); + expect(firstWeight).toBe(50); + expect(secondWeight).toBe(50); + expect(component.conditionWeightForm.dirty).toBe(true); + }); + + it('should disable weight inputs when equal method is selected', () => { + component.conditionWeightForm.get('weightingMethod')?.setValue('equal'); + fixture.detectChanges(); + + component.conditionsFormArray.controls.forEach((control) => { + expect(control.get('assignmentWeight')?.disabled).toBe(true); + }); + }); + }); + + describe('Weight Distribution', () => { + it('should distribute weights equally with proper rounding for 3 conditions', async () => { + const threeConditionsData = { + ...mockDialogData, + params: { + experimentWeightsArray: [ + { conditionId: '1', conditionCode: 'A', assignmentWeight: 0 }, + { conditionId: '2', conditionCode: 'B', assignmentWeight: 0 }, + { conditionId: '3', conditionCode: 'C', assignmentWeight: 0 }, + ], + }, + }; + + const { component } = await setupComponent(threeConditionsData); + component.distributeWeightsEqually(); + + const total = component.getCurrentTotal(); + expect(total).toBe(100); + }); + }); + + describe('Form Validation', () => { + beforeEach(() => { + component.conditionWeightForm.get('weightingMethod')?.setValue('custom'); + fixture.detectChanges(); + }); + + it('should validate that weights are required', () => { + const weightControl = component.getWeightControl(0); + weightControl.setValue(''); + weightControl.markAsTouched(); + + expect(weightControl.hasError('required')).toBe(true); + }); + + it('should validate minimum weight value', () => { + const weightControl = component.getWeightControl(0); + weightControl.setValue(-1); + + expect(weightControl.hasError('min')).toBe(true); + }); + + it('should validate maximum weight value', () => { + const weightControl = component.getWeightControl(0); + weightControl.setValue(101); + + expect(weightControl.hasError('max')).toBe(true); + }); + + it('should validate decimal places', () => { + const weightControl = component.getWeightControl(0); + weightControl.setValue(25.123); + + expect(weightControl.hasError('tooManyDecimals')).toBe(true); + }); + + it('should validate total weight equals 100', () => { + component.getWeightControl(0).setValue(60); + component.getWeightControl(1).setValue(30); + component.onWeightChange(); + + expect(component.conditionsFormArray.hasError('totalWeightInvalid')).toBe(true); + }); + + it('should pass validation when total weight equals 100', () => { + component.getWeightControl(0).setValue(60); + component.getWeightControl(1).setValue(40); + component.onWeightChange(); + + expect(component.conditionsFormArray.hasError('totalWeightInvalid')).toBe(false); + }); + + it('should allow small rounding errors in total weight', () => { + component.getWeightControl(0).setValue(33.33); + component.getWeightControl(1).setValue(66.67); + component.onWeightChange(); + + expect(component.conditionsFormArray.hasError('totalWeightInvalid')).toBe(false); + }); + }); + + describe('Total Weight Status', () => { + beforeEach(() => { + component.conditionWeightForm.get('weightingMethod')?.setValue('custom'); + }); + + it('should return correct total weight status when valid', () => { + component.getWeightControl(0).setValue(50); + component.getWeightControl(1).setValue(50); + + const status = component.getTotalWeightStatus(); + expect(Object.keys(status).length).toBe(0); // No errors when valid + }); + + it('should return correct total weight status when invalid', () => { + component.getWeightControl(0).setValue(60); + component.getWeightControl(1).setValue(30); + component.onWeightChange(); + + const status = component.getTotalWeightStatus(); + expect(status.totalWeightInvalid).toBeDefined(); + expect(status.totalWeightInvalid.actualTotal).toBe(90); + expect(status.totalWeightInvalid.expectedTotal).toBe(100); + }); + }); + + describe('Primary Action Button', () => { + it('should be disabled when form is pristine (initially)', (done) => { + component.isPrimaryButtonDisabled$.subscribe((disabled) => { + expect(disabled).toBe(true); + done(); + }); + }); + + it('should be disabled when form is invalid even if dirty', (done) => { + component.conditionWeightForm.get('weightingMethod')?.setValue('custom'); + component.getWeightControl(0).setValue(60); + component.getWeightControl(1).setValue(30); // Total = 90, invalid + + component.isPrimaryButtonDisabled$.subscribe((disabled) => { + expect(disabled).toBe(true); + done(); + }); + }); + + it('should be enabled when form is valid and dirty', (done) => { + // Change from equal to custom to make it dirty, then back to equal + component.conditionWeightForm.get('weightingMethod')?.setValue('custom'); + component.conditionWeightForm.get('weightingMethod')?.setValue('equal'); + + component.isPrimaryButtonDisabled$.subscribe((disabled) => { + expect(disabled).toBe(false); + done(); + }); + }); + + it('should be enabled when custom weights are valid and form is dirty', (done) => { + component.conditionWeightForm.get('weightingMethod')?.setValue('custom'); + component.getWeightControl(0).setValue(60); + component.getWeightControl(1).setValue(40); // Total = 100, valid + + component.isPrimaryButtonDisabled$.subscribe((disabled) => { + expect(disabled).toBe(false); + done(); + }); + }); + }); + + describe('Modal Actions', () => { + it('should close dialog with result when form is valid', () => { + // Make form dirty and valid + component.conditionWeightForm.get('weightingMethod')?.setValue('custom'); + component.conditionWeightForm.get('weightingMethod')?.setValue('equal'); + + component.onPrimaryActionBtnClicked(); + + expect(mockDialogRef.close).toHaveBeenCalledWith( + expect.arrayContaining([ + expect.objectContaining({ + conditionId: '1', + conditionCode: 'Control', + assignmentWeight: expect.any(Number), + }), + expect.objectContaining({ + conditionId: '2', + conditionCode: 'Treatment', + assignmentWeight: expect.any(Number), + }), + ]) + ); + }); + + it('should not close dialog when form is invalid', () => { + // Keep form pristine or make it invalid + component.conditionWeightForm.get('weightingMethod')?.setValue('custom'); + component.getWeightControl(0).setValue(60); + component.getWeightControl(1).setValue(30); // Invalid total + + component.onPrimaryActionBtnClicked(); + + expect(mockDialogRef.close).not.toHaveBeenCalled(); + }); + + it('should close dialog without result when closeModal is called', () => { + component.closeModal(); + + expect(mockDialogRef.close).toHaveBeenCalledWith(); + }); + }); + + describe('Decimal Validator', () => { + it('should return null for valid decimal', () => { + const control = { value: '25.50' } as any; + const result = component.decimalValidator(control); + expect(result).toBeNull(); + }); + + it('should return error for too many decimal places', () => { + const control = { value: '25.123' } as any; + const result = component.decimalValidator(control); + expect(result).toEqual({ tooManyDecimals: true }); + }); + + it('should return error for invalid number', () => { + const control = { value: 'abc' } as any; + const result = component.decimalValidator(control); + expect(result).toEqual({ invalidNumber: true }); + }); + + it('should return null for empty value', () => { + const control = { value: '' } as any; + const result = component.decimalValidator(control); + expect(result).toBeNull(); + }); + }); + + describe('Weight Control Access', () => { + it('should return correct weight control for given index', () => { + const control = component.getWeightControl(0); + expect(control).toBe(component.conditionsFormArray.at(0).get('assignmentWeight')); + }); + }); + + describe('Current Total Calculation', () => { + it('should calculate current total correctly', () => { + component.conditionWeightForm.get('weightingMethod')?.setValue('custom'); + component.getWeightControl(0).setValue(30); + component.getWeightControl(1).setValue(45); + + const total = component.getCurrentTotal(); + expect(total).toBe(75); + }); + + it('should handle invalid weight values in total calculation', () => { + component.conditionWeightForm.get('weightingMethod')?.setValue('custom'); + component.getWeightControl(0).setValue('invalid'); + component.getWeightControl(1).setValue(50); + + const total = component.getCurrentTotal(); + expect(total).toBe(50); + }); + }); + + describe('Form Dirty State Management', () => { + it('should mark form as dirty when weighting method changes', () => { + expect(component.conditionWeightForm.pristine).toBe(true); + + component.conditionWeightForm.get('weightingMethod')?.setValue('custom'); + + expect(component.conditionWeightForm.dirty).toBe(true); + }); + + it('should mark form as dirty when weight values change through onWeightChange', () => { + component.conditionWeightForm.get('weightingMethod')?.setValue('custom'); + component.conditionWeightForm.markAsPristine(); // Reset to pristine + + component.getWeightControl(0).setValue(75); + // onWeightChange is called automatically by the input event in the template + // but we need to call it manually in tests + component.onWeightChange(); + + // The form should be marked as dirty through the weight change + expect(component.conditionWeightForm.pristine).toBe(true); // onWeightChange doesn't mark as dirty, only validates + }); + }); +}); diff --git a/frontend/projects/upgrade/src/app/features/dashboard/experiments/modals/edit-condition-weights-modal/edit-condition-weights-modal.component.ts b/frontend/projects/upgrade/src/app/features/dashboard/experiments/modals/edit-condition-weights-modal/edit-condition-weights-modal.component.ts new file mode 100644 index 0000000000..898d9ffa0e --- /dev/null +++ b/frontend/projects/upgrade/src/app/features/dashboard/experiments/modals/edit-condition-weights-modal/edit-condition-weights-modal.component.ts @@ -0,0 +1,300 @@ +import { ChangeDetectionStrategy, Component, Inject, OnInit } from '@angular/core'; +import { MAT_DIALOG_DATA, MatDialog, MatDialogRef } from '@angular/material/dialog'; +import { + FormBuilder, + FormGroup, + Validators, + ReactiveFormsModule, + FormArray, + FormControl, + AbstractControl, + ValidationErrors, +} from '@angular/forms'; +import { CommonModule } from '@angular/common'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatInputModule } from '@angular/material/input'; +import { MatButtonModule } from '@angular/material/button'; +import { MatRadioModule } from '@angular/material/radio'; +import { MatTableModule } from '@angular/material/table'; +import { TranslateModule, TranslateService } from '@ngx-translate/core'; +import { Observable, combineLatest, map, startWith } from 'rxjs'; +import { CommonModalComponent } from '../../../../../shared-standalone-component-lib/components'; +import { CommonFormHelpersService } from '../../../../../shared/services/common-form-helpers.service'; +import { CommonModalConfig } from '../../../../../shared-standalone-component-lib/components/common-modal/common-modal.types'; + +export interface ConditionWeightUpdate { + conditionId: string; + conditionCode: string; + assignmentWeight: number; +} +@Component({ + selector: 'edit-condition-weights-modal', + imports: [ + CommonModalComponent, + MatTableModule, + MatFormFieldModule, + MatInputModule, + MatButtonModule, + MatRadioModule, + CommonModule, + ReactiveFormsModule, + TranslateModule, + ], + templateUrl: './edit-condition-weights-modal.component.html', + styleUrl: './edit-condition-weights-modal.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class EditConditionWeightsModalComponent implements OnInit { + isPrimaryButtonDisabled$: Observable; + conditionWeightForm: FormGroup; + displayedColumns: string[] = ['condition', 'weight']; + + conditions: ConditionWeightUpdate[] = []; + weightingMethods = [ + { + value: 'equal', + name: this.translate.instant('experiments.edit-condition-weights-modal.equal-assignment-weights.label.text'), + description: this.translate.instant( + 'experiments.edit-condition-weights-modal.equal-assignment-weights.description.text' + ), + disabled: false, + }, + { + value: 'custom', + name: this.translate.instant('experiments.edit-condition-weights-modal.custom-percentages.label.text'), + description: this.translate.instant( + 'experiments.edit-condition-weights-modal.custom-percentages.description.text' + ), + disabled: false, + }, + ]; + + constructor( + @Inject(MAT_DIALOG_DATA) + public config: CommonModalConfig<{ experimentWeightsArray: ConditionWeightUpdate[] }>, + public dialog: MatDialog, + private readonly formBuilder: FormBuilder, + private translate: TranslateService, + public dialogRef: MatDialogRef + ) {} + + ngOnInit(): void { + this.createconditionWeightForm(); + } + + createconditionWeightForm(): void { + const { experimentWeightsArray } = this.config.params; + this.conditions = experimentWeightsArray; + + // Determine initial weighting method based on existing weights + const initialWeightingMethod = this.determineInitialWeightingMethod(experimentWeightsArray); + + // Create FormArray for conditions with individual validators + const conditionsFormArray = this.formBuilder.array( + experimentWeightsArray.map((condition) => + this.formBuilder.group({ + conditionCode: [condition.conditionCode], + assignmentWeight: [ + condition.assignmentWeight, + [Validators.required, Validators.min(0), Validators.max(100), this.decimalValidator], + ], + }) + ), + [this.totalWeightValidator] // Array-level validator for sum = 100 + ); + + this.conditionWeightForm = this.formBuilder.group({ + weightingMethod: [initialWeightingMethod, Validators.required], + conditions: conditionsFormArray, + }); + + // Set up form validation with weight sum checking + this.setupFormValidation(); + + // Watch for weighting method changes + this.watchWeightingMethodChanges(); + + // Set initial input state based on the determined method + if (initialWeightingMethod === 'equal') { + this.disableWeightInputs(); + } else { + this.enableWeightInputs(); + } + } + + private determineInitialWeightingMethod(conditions: ConditionWeightUpdate[]): string { + if (!conditions || conditions.length === 0) { + return 'equal'; + } + + if (conditions.length === 1) { + // Single condition should always be 100% + return Math.abs(conditions[0].assignmentWeight - 100) < 0.01 ? 'equal' : 'custom'; + } + + const expectedEqualWeight = 100 / conditions.length; + + // Check if all weights are close to the expected equal distribution + const areWeightsEquallyDistributed = conditions.every( + (condition) => Math.abs(condition.assignmentWeight - expectedEqualWeight) < 0.01 + ); + + // Additional check: ensure total is close to 100% + const totalWeight = conditions.reduce((sum, condition) => sum + condition.assignmentWeight, 0); + const isTotalValid = Math.abs(totalWeight - 100) < 0.01; + + // Return 'equal' only if weights are equally distributed AND total is valid + return areWeightsEquallyDistributed && isTotalValid ? 'equal' : 'custom'; + } + + private setupFormValidation(): void { + this.isPrimaryButtonDisabled$ = combineLatest([ + this.conditionWeightForm.statusChanges.pipe(startWith(this.conditionWeightForm.status)), + this.conditionWeightForm.valueChanges.pipe(startWith(this.conditionWeightForm.value)), + ]).pipe( + map(([status]) => { + return ( + status === 'INVALID' || + this.conditionsFormArray.hasError('totalWeightInvalid') || + this.conditionWeightForm.pristine + ); + }) + ); + } + + private watchWeightingMethodChanges(): void { + this.conditionWeightForm.get('weightingMethod')?.valueChanges.subscribe((method) => { + this.conditionWeightForm.markAsDirty(); + + if (method === 'equal') { + this.distributeWeightsEqually(); + this.disableWeightInputs(); + } else if (method === 'custom') { + this.enableWeightInputs(); + } else if (method === null) { + this.disableWeightInputs(); + } + }); + } + + // Custom validator for decimal precision + decimalValidator(control: AbstractControl): ValidationErrors | null { + if (control.value == null || control.value === '') { + return null; + } + + const value = parseFloat(control.value); + if (isNaN(value)) { + return { invalidNumber: true }; + } + + // Allow up to 2 decimal places + const decimalPlaces = (control.value.toString().split('.')[1] || '').length; + if (decimalPlaces > 2) { + return { tooManyDecimals: true }; + } + + return null; + } + + // Array-level validator for total weight and individual control errors + totalWeightValidator(formArray: AbstractControl): ValidationErrors | null { + if (!(formArray instanceof FormArray)) { + return null; + } + + const [total, formErrors] = formArray.controls.reduce( + (acc, control) => { + const weight = control.get('assignmentWeight')?.value; + return [acc[0] + (parseFloat(weight) || 0), { ...acc[1], ...control.get('assignmentWeight')?.errors }]; + }, + [0, {}] as [number, ValidationErrors] + ); + const isValid = Math.abs(total - 100) < 0.01; + const totalValidation = isValid + ? {} + : { + totalWeightInvalid: { + actualTotal: Math.round(total * 100) / 100, + expectedTotal: 100, + }, + }; + const allErrors = { + ...formErrors, + ...totalValidation, + }; + return Object.keys(allErrors).length === 0 ? null : allErrors; + } + + get conditionsFormArray(): FormArray { + return this.conditionWeightForm.get('conditions') as FormArray; + } + + getWeightControl(index: number): FormControl { + return this.conditionsFormArray.at(index).get('assignmentWeight') as FormControl; + } + + getCurrentTotal(): number { + return this.conditionsFormArray.controls.reduce((sum, control) => { + const weight = control.get('assignmentWeight')?.value; + return sum + (parseFloat(weight) || 0); + }, 0); + } + + distributeWeightsEqually(): void { + const equalWeight = Math.round((100 / this.conditions.length) * 100) / 100; + let remainingWeight = 100; + + this.conditionsFormArray.controls.forEach((control, index) => { + if (index === this.conditionsFormArray.controls.length - 1) { + // Last condition gets the remaining weight to ensure total = 100 + control.get('assignmentWeight')?.setValue(Math.round(remainingWeight * 100) / 100); + } else { + control.get('assignmentWeight')?.setValue(equalWeight); + remainingWeight -= equalWeight; + } + }); + } + + disableWeightInputs(): void { + this.conditionsFormArray.controls.forEach((control) => { + control.get('assignmentWeight')?.disable(); + }); + } + + enableWeightInputs(): void { + this.conditionsFormArray.controls.forEach((control) => { + control.get('assignmentWeight')?.enable(); + }); + } + + onWeightChange(): void { + // Force validation update + this.conditionsFormArray.updateValueAndValidity(); + } + + // Helper method to get total weight status for display + getTotalWeightStatus(): ValidationErrors { + return this.conditionsFormArray.errors; + } + + onPrimaryActionBtnClicked() { + if (this.conditionWeightForm.valid) { + const result: ConditionWeightUpdate[] = this.conditionsFormArray.controls.map((control, index) => ({ + conditionId: this.conditions[index].conditionId, + conditionCode: control.get('conditionCode')?.value, + assignmentWeight: control.get('assignmentWeight')?.value, + })); + + // Close dialog and return the result + this.dialogRef.close(result); + } else { + // If the form is invalid, manually mark all form controls as touched + CommonFormHelpersService.triggerTouchedToDisplayErrors(this.conditionWeightForm); + } + } + + closeModal() { + this.dialogRef.close(); + } +} diff --git a/frontend/projects/upgrade/src/app/features/dashboard/experiments/modals/upsert-experiment-modal/upsert-experiment-modal.component.ts b/frontend/projects/upgrade/src/app/features/dashboard/experiments/modals/upsert-experiment-modal/upsert-experiment-modal.component.ts index c2b18d9507..990a7d83f9 100644 --- a/frontend/projects/upgrade/src/app/features/dashboard/experiments/modals/upsert-experiment-modal/upsert-experiment-modal.component.ts +++ b/frontend/projects/upgrade/src/app/features/dashboard/experiments/modals/upsert-experiment-modal/upsert-experiment-modal.component.ts @@ -19,7 +19,6 @@ import { ExperimentService } from '../../../../../core/experiments/experiments.s import { UPSERT_EXPERIMENT_ACTION, UpsertExperimentParams, - ExperimentDesignTypes, Experiment, ExperimentVM, IContextMetaData, @@ -38,6 +37,7 @@ import { POST_EXPERIMENT_RULE, SUPPORTED_MOOCLET_ALGORITHMS, ASSIGNMENT_ALGORITHM_DISPLAY_MAP, + EXPERIMENT_TYPE, } from 'upgrade_types'; import { CommonModalConfig } from '../../../../../shared-standalone-component-lib/components/common-modal/common-modal.types'; import { StratificationFactorsService } from '../../../../../core/stratification-factors/stratification-factors.service'; @@ -91,7 +91,7 @@ export class UpsertExperimentModalComponent implements OnInit, OnDestroy { // Enum references for template UPSERT_EXPERIMENT_ACTION = UPSERT_EXPERIMENT_ACTION; - ExperimentDesignTypes = ExperimentDesignTypes; + EXPERIMENT_TYPE = EXPERIMENT_TYPE; ASSIGNMENT_UNIT = ASSIGNMENT_UNIT; CONSISTENCY_RULE = CONSISTENCY_RULE; CONDITION_ORDER = CONDITION_ORDER; @@ -100,12 +100,12 @@ export class UpsertExperimentModalComponent implements OnInit, OnDestroy { experimentTypes = [ { - value: ExperimentDesignTypes.SIMPLE, + value: EXPERIMENT_TYPE.SIMPLE, description: 'experiments.upsert-experiment-modal.experiment-type-simple-description.text', disabled: false, }, { - value: ExperimentDesignTypes.FACTORIAL, + value: EXPERIMENT_TYPE.FACTORIAL, description: 'experiments.upsert-experiment-modal.experiment-type-factorial-description.text', disabled: true, // Disabled for v2 - will be enabled in future versions }, @@ -266,8 +266,7 @@ export class UpsertExperimentModalComponent implements OnInit, OnDestroy { const name = action === UPSERT_EXPERIMENT_ACTION.EDIT ? sourceExperiment?.name : ''; const description = sourceExperiment?.description || ''; const appContext = sourceExperiment?.context?.[0] || ''; - const experimentType = - sourceExperiment?.type === 'Factorial' ? ExperimentDesignTypes.FACTORIAL : ExperimentDesignTypes.SIMPLE; + const experimentType = sourceExperiment?.type === 'Factorial' ? EXPERIMENT_TYPE.FACTORIAL : EXPERIMENT_TYPE.SIMPLE; const unitOfAssignment = sourceExperiment?.assignmentUnit || ASSIGNMENT_UNIT.INDIVIDUAL; const consistencyRule = sourceExperiment?.consistencyRule || CONSISTENCY_RULE.INDIVIDUAL; const conditionOrder = sourceExperiment?.conditionOrder || null; diff --git a/frontend/projects/upgrade/src/app/features/dashboard/experiments/pages/experiment-details-page/experiment-details-page-content/experiment-conditions-section-card/experiment-conditions-section-card.component.html b/frontend/projects/upgrade/src/app/features/dashboard/experiments/pages/experiment-details-page/experiment-details-page-content/experiment-conditions-section-card/experiment-conditions-section-card.component.html index 15ed658fe6..a4f01d0655 100644 --- a/frontend/projects/upgrade/src/app/features/dashboard/experiments/pages/experiment-details-page/experiment-details-page-content/experiment-conditions-section-card/experiment-conditions-section-card.component.html +++ b/frontend/projects/upgrade/src/app/features/dashboard/experiments/pages/experiment-details-page/experiment-details-page-content/experiment-conditions-section-card/experiment-conditions-section-card.component.html @@ -30,5 +30,6 @@ [isLoading$]="experimentService.isLoadingSelectedExperiment$" [actionsDisabled]="!(permissions$ | async)?.experiments.update" (rowAction)="onRowAction($event, experiment.id)" + (editWeights)="onEditWeights($event, experiment)" > diff --git a/frontend/projects/upgrade/src/app/features/dashboard/experiments/pages/experiment-details-page/experiment-details-page-content/experiment-conditions-section-card/experiment-conditions-section-card.component.ts b/frontend/projects/upgrade/src/app/features/dashboard/experiments/pages/experiment-details-page/experiment-details-page-content/experiment-conditions-section-card/experiment-conditions-section-card.component.ts index eb84fdc0f5..98e5eeab20 100644 --- a/frontend/projects/upgrade/src/app/features/dashboard/experiments/pages/experiment-details-page/experiment-details-page-content/experiment-conditions-section-card/experiment-conditions-section-card.component.ts +++ b/frontend/projects/upgrade/src/app/features/dashboard/experiments/pages/experiment-details-page/experiment-details-page-content/experiment-conditions-section-card/experiment-conditions-section-card.component.ts @@ -16,9 +16,12 @@ import { EXPERIMENT_ROW_ACTION, ExperimentCondition, ExperimentConditionRowActionEvent, + ExperimentVM, } 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'; +import { ConditionWeightUpdate } from '../../../../modals/edit-condition-weights-modal/edit-condition-weights-modal.component'; @Component({ selector: 'app-experiment-conditions-section-card', @@ -53,7 +56,11 @@ export class ExperimentConditionsSectionCardComponent implements OnInit { }, ]; - constructor(private experimentService: ExperimentService, private authService: AuthService) {} + constructor( + private experimentService: ExperimentService, + private authService: AuthService, + private dialogService: DialogService + ) {} ngOnInit() { this.permissions$ = this.authService.userPermissions$; @@ -106,4 +113,15 @@ export class ExperimentConditionsSectionCardComponent implements OnInit { // TODO: Implement delete functionality when dialog service is available console.log('Delete condition:', condition); } + + onEditWeights(conditions: ExperimentCondition[], experiment: ExperimentVM): void { + this.dialogService + .openEditConditionWeightsModal(conditions) + .subscribe((result: ConditionWeightUpdate[] | undefined) => { + if (result) { + // Update the experiment with new condition weights + this.experimentService.updateExperimentConditionWeights(experiment, result); + } + }); + } } diff --git a/frontend/projects/upgrade/src/app/features/dashboard/experiments/pages/experiment-details-page/experiment-details-page-content/experiment-conditions-section-card/experiment-conditions-table/experiment-conditions-table.component.ts b/frontend/projects/upgrade/src/app/features/dashboard/experiments/pages/experiment-details-page/experiment-details-page-content/experiment-conditions-section-card/experiment-conditions-table/experiment-conditions-table.component.ts index ba25f5c5a2..68e198ace8 100644 --- a/frontend/projects/upgrade/src/app/features/dashboard/experiments/pages/experiment-details-page/experiment-details-page-content/experiment-conditions-section-card/experiment-conditions-table/experiment-conditions-table.component.ts +++ b/frontend/projects/upgrade/src/app/features/dashboard/experiments/pages/experiment-details-page/experiment-details-page-content/experiment-conditions-section-card/experiment-conditions-table/experiment-conditions-table.component.ts @@ -36,6 +36,7 @@ export class ExperimentConditionsTableComponent { @Input() isLoading$: Observable; @Input() actionsDisabled?: boolean = false; @Output() rowAction = new EventEmitter(); + @Output() editWeights = new EventEmitter(); displayedColumns: string[] = ['condition', 'payload', 'weight', 'weightEdit', 'actions']; @@ -109,7 +110,6 @@ export class ExperimentConditionsTableComponent { } onEditWeightsClick(): void { - // TODO: Implement edit weights functionality when dialog service is available - console.log('Edit condition weights clicked'); + this.editWeights.emit(this.conditions); } } diff --git a/frontend/projects/upgrade/src/app/features/dashboard/home/components/experiment-overview/experiment-overview.component.ts b/frontend/projects/upgrade/src/app/features/dashboard/home/components/experiment-overview/experiment-overview.component.ts index d494d28933..f19d7e68c2 100644 --- a/frontend/projects/upgrade/src/app/features/dashboard/home/components/experiment-overview/experiment-overview.component.ts +++ b/frontend/projects/upgrade/src/app/features/dashboard/home/components/experiment-overview/experiment-overview.component.ts @@ -27,7 +27,6 @@ import { ExperimentVM, NewExperimentPaths, IContextMetaData, - ExperimentDesignTypes, OverviewFormWarningStatus, } from '../../../../../core/experiments/store/experiments.model'; import { ENTER, COMMA } from '@angular/cdk/keycodes'; @@ -72,7 +71,7 @@ export class ExperimentOverviewComponent implements OnInit, OnDestroy { { value: CONDITION_ORDER.RANDOM_ROUND_ROBIN }, { value: CONDITION_ORDER.ORDERED_ROUND_ROBIN }, ]; - designTypes = [{ value: ExperimentDesignTypes.SIMPLE }, { value: ExperimentDesignTypes.FACTORIAL }]; + designTypes = [{ value: EXPERIMENT_TYPE.SIMPLE }, { value: EXPERIMENT_TYPE.FACTORIAL }]; assignmentAlgorithms = [ { value: ASSIGNMENT_ALGORITHM.RANDOM }, { value: ASSIGNMENT_ALGORITHM.STRATIFIED_RANDOM_SAMPLING }, @@ -143,7 +142,7 @@ export class ExperimentOverviewComponent implements OnInit, OnDestroy { groupType: [null], consistencyRule: [null, Validators.required], conditionOrder: [null], - designType: [ExperimentDesignTypes.SIMPLE, Validators.required], + designType: [EXPERIMENT_TYPE.SIMPLE, Validators.required], assignmentAlgorithm: [ASSIGNMENT_ALGORITHM.RANDOM, Validators.required], stratificationFactor: [null], context: [null, Validators.required], 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 5e1878ccf7..637e5f087c 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 { UPSERT_EXPERIMENT_ACTION } from '../../core/experiments/store/experiments.model'; +import { ExperimentCondition, UPSERT_EXPERIMENT_ACTION } from '../../core/experiments/store/experiments.model'; +import { + ConditionWeightUpdate, + EditConditionWeightsModalComponent, +} from '../../features/dashboard/experiments/modals/edit-condition-weights-modal/edit-condition-weights-modal.component'; +import { Observable } from 'rxjs'; export interface ImportModalParams { importTypeAdapterToken: InjectionToken; @@ -363,6 +368,32 @@ export class DialogService { return this.openUpsertPrivateSegmentListModal(commonModalConfig); } + openEditConditionWeightsModal(conditions: ExperimentCondition[]): Observable { + const dialogRef = this.dialog.open(EditConditionWeightsModalComponent, { + panelClass: ['experiment-modal', 'modal-shadow'], + hasBackdrop: true, + autoFocus: false, + backdropClass: 'modal-backdrop', + width: ModalSize.STANDARD, + + data: { + title: 'experiments.edit-condition-weights-modal.title.text', + primaryActionBtnLabel: 'Save', + primaryActionBtnColor: 'primary', + cancelBtnLabel: 'Cancel', + params: { + experimentWeightsArray: conditions.map((condition) => ({ + conditionId: condition.id, + conditionCode: condition.conditionCode, + assignmentWeight: condition.assignmentWeight || 0, + })), + }, + }, + }); + + return dialogRef.afterClosed(); + } + openAddListModal(appContext: string, segmentId: string, segmentType: SEGMENT_TYPE) { const commonModalConfig: CommonModalConfig = { title: segmentType === SEGMENT_TYPE.GLOBAL_EXCLUDE ? 'Add Exclude List' : 'Add List', diff --git a/frontend/projects/upgrade/src/assets/i18n/en.json b/frontend/projects/upgrade/src/assets/i18n/en.json index 5fb7e3d7d0..f636554308 100644 --- a/frontend/projects/upgrade/src/assets/i18n/en.json +++ b/frontend/projects/upgrade/src/assets/i18n/en.json @@ -445,6 +445,15 @@ "experiments.upsert-exclude-list-modal.name-hint.text": "The name for this exclude list.", "experiments.upsert-list-modal.values-label.text": "Values", "experiments.upsert-list-modal.values-placeholder.text": "Values separated by commas", + "experiments.edit-condition-weights-modal.title.text": "Edit Condition Weights", + "experiments.edit-condition-weights-modal.method-header.text": "Weighting Method", + "experiments.edit-condition-weights-modal.weights-sum-validation.text": "The weights must sum up to 100%.", + "experiments.edit-condition-weights-modal.weights-range-validation.text": "Each weight must be between 0 and 100.", + "experiments.edit-condition-weights-modal.weights-decimal-validation.text": "Weights can have at most two decimal places.", + "experiments.edit-condition-weights-modal.equal-assignment-weights.label.text": "Weight Equally", + "experiments.edit-condition-weights-modal.equal-assignment-weights.description.text": "Equally distribute weight percentages across all conditions.", + "experiments.edit-condition-weights-modal.custom-percentages.label.text": "Custom Percentages", + "experiments.edit-condition-weights-modal.custom-percentages.description.text": "Define a custom weight percentage for each condition.", "feature-flags.details-created-on.text": "Created on: ", "feature-flags.details-updated-at.text": "Updated at: ", "feature-flags.global-name.text": "Name",