From 4238a9f978758f19a38630512fc2cfad49fd2a21 Mon Sep 17 00:00:00 2001 From: Benjamin Blanchard Date: Tue, 9 Sep 2025 14:57:13 -0400 Subject: [PATCH 1/6] add edit-condition-weights modal --- .../core/experiments/experiments.service.ts | 26 ++ .../experiments/store/experiments.model.ts | 9 +- ...dit-condition-weights-modal.component.html | 118 ++++++ ...dit-condition-weights-modal.component.scss | 134 +++++++ ...-condition-weights-modal.component.spec.ts | 347 ++++++++++++++++++ .../edit-condition-weights-modal.component.ts | 261 +++++++++++++ .../upsert-experiment-modal.component.ts | 11 +- ...ent-conditions-section-card.component.html | 1 + ...iment-conditions-section-card.component.ts | 20 +- .../experiment-conditions-table.component.ts | 4 +- .../experiment-overview.component.ts | 5 +- .../shared/services/common-dialog.service.ts | 32 +- .../projects/upgrade/src/assets/i18n/en.json | 1 + 13 files changed, 949 insertions(+), 20 deletions(-) create mode 100644 frontend/projects/upgrade/src/app/features/dashboard/experiments/modals/edit-condition-weights-modal/edit-condition-weights-modal.component.html create mode 100644 frontend/projects/upgrade/src/app/features/dashboard/experiments/modals/edit-condition-weights-modal/edit-condition-weights-modal.component.scss create mode 100644 frontend/projects/upgrade/src/app/features/dashboard/experiments/modals/edit-condition-weights-modal/edit-condition-weights-modal.component.spec.ts create mode 100644 frontend/projects/upgrade/src/app/features/dashboard/experiments/modals/edit-condition-weights-modal/edit-condition-weights-modal.component.ts 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..5ea1366c82 --- /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,118 @@ + +
+
+ + + +
+ + {{ method.name | titlecase }} + + + {{ method.description | translate }} + +
+
+
+
+ +
+ + + + + + + + + + + + + + + + + + + + + +
+ Condition + + {{ condition.conditionCode }} + + Weight (%) + + + + % + + + + Weight is required + + + Weight cannot be negative + + + Weight cannot exceed 100% + + + Maximum 2 decimal places + + + Invalid number + + +
+ No conditions available +
+ + +
+ + Total: {{ getTotalWeightStatus().total }}% + + + {{ getTotalWeightStatus().error }} + + + ✓ Weights are balanced + +
+
+
+
\ 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..5cc2c4aa3f --- /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,134 @@ +.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 { + ::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); + } + } + } + + .condition-column { + width: 65%; + padding-left: 32px; + } + + .weight-column { + width: 35%; + text-align: center; + + .weight-input { + width: 90px; + + .mat-mdc-form-field-subscript-wrapper { + display: none; + } + } + } + } +} +.weight-summary { + margin-top: 16px; + padding: 12px; + border: 1px solid #e0e0e0; + border-radius: 4px; + text-align: center; + + &.error { + border-color: #f44336; + background-color: #ffebee; + } + + .success-text { + color: #4caf50; + } + + mat-error { + margin: 0; + } +} + +.weight-method-section { + margin-bottom: 24px; + + .section-label { + display: block; + margin-bottom: 16px; + } + + mat-radio-group { + display: flex; + flex-direction: column; + gap: 12px; + } + + .radio-content { + display: flex; + flex-direction: column; + + .radio-label { + margin-bottom: 4px; + } + + .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..e62e244038 --- /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,347 @@ +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: [ + { id: '1', conditionCode: 'Control', assignmentWeight: 50 }, + { id: '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 null weighting method', () => { + expect(component.conditionWeightForm.get('weightingMethod')?.value).toBeNull(); + }); + + 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', () => { + component.conditionsFormArray.controls.forEach((control) => { + expect(control.get('assignmentWeight')?.disabled).toBe(true); + }); + }); + }); + + describe('Weighting Method Changes', () => { + it('should enable weight inputs 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); + }); + }); + + it('should distribute weights equally when equal method is selected', () => { + 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); + }); + + 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: [ + { id: '1', conditionCode: 'A', assignmentWeight: 0 }, + { id: '2', conditionCode: 'B', assignmentWeight: 0 }, + { id: '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(status.total).toBe(100); + expect(status.isValid).toBe(true); + expect(status.error).toBeUndefined(); + }); + + it('should return correct total weight status when invalid', () => { + component.getWeightControl(0).setValue(60); + component.getWeightControl(1).setValue(30); + + const status = component.getTotalWeightStatus(); + expect(status.total).toBe(90); + expect(status.isValid).toBe(false); + expect(status.error).toContain('Total must equal 100%'); + }); + }); + + describe('Primary Action Button', () => { + it('should be disabled when form is invalid', (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', (done) => { + component.conditionWeightForm.get('weightingMethod')?.setValue('equal'); + + component.isPrimaryButtonDisabled$.subscribe((disabled) => { + expect(disabled).toBe(false); + done(); + }); + }); + }); + + describe('Modal Actions', () => { + it('should close dialog with result when form is valid', () => { + 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', () => { + // Form is invalid because no weighting method is selected + 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); + }); + }); +}); 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..f9b08021cb --- /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,261 @@ +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 { NgIf, 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 } 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, + NgIf, + ], + 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: 'Weight Equally', + description: 'Equally distribute weight percentages across all conditions.', + disabled: false, + }, + { + value: 'custom', + name: 'Custom Percentages', + description: 'Define a custom weight percentage for each condition.', + disabled: false, // Disabled for v2 - will be enabled in future versions + }, + ]; + + constructor( + @Inject(MAT_DIALOG_DATA) + public config: CommonModalConfig<{ experimentWeightsArray: ConditionWeightUpdate[] }>, + public dialog: MatDialog, + private readonly formBuilder: FormBuilder, + public dialogRef: MatDialogRef + ) {} + + ngOnInit(): void { + this.createconditionWeightForm(); + } + + createconditionWeightForm(): void { + const { experimentWeightsArray } = this.config.params; + this.conditions = 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: [null, Validators.required], + conditions: conditionsFormArray, + }); + + // Set up form validation with weight sum checking + this.setupFormValidation(); + + // Watch for weighting method changes + this.watchWeightingMethodChanges(); + + // Initially disable weight inputs until method is selected + this.disableWeightInputs(); + } + + 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, value]) => { + return status === 'INVALID' || this.conditionsFormArray.hasError('totalWeightInvalid'); + }) + ); + } + + private watchWeightingMethodChanges(): void { + this.conditionWeightForm.get('weightingMethod')?.valueChanges.subscribe((method) => { + if (method === 'equal') { + this.distributeWeightsEqually(); + this.disableWeightInputs(); + } else if (method === 'custom') { + this.enableWeightInputs(); + } else if (method === null) { + // No method selected - disable inputs + 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 to ensure total weight equals 100 + totalWeightValidator(formArray: AbstractControl): ValidationErrors | null { + if (!(formArray instanceof FormArray)) { + return null; + } + + const total = formArray.controls.reduce((sum, control) => { + const weight = control.get('assignmentWeight')?.value; + return sum + (parseFloat(weight) || 0); + }, 0); + + // Allow for small rounding errors (within 0.01) + const isValid = Math.abs(total - 100) < 0.01; + + return isValid + ? null + : { + totalWeightInvalid: { + actualTotal: Math.round(total * 100) / 100, + expectedTotal: 100, + }, + }; + } + + 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(): { total: number; isValid: boolean; error?: string } { + const total = this.getCurrentTotal(); + const isValid = Math.abs(total - 100) < 0.01; + + return { + total: Math.round(total * 100) / 100, + isValid, + error: !isValid ? `Total must equal 100% (current: ${Math.round(total * 100) / 100}%)` : undefined, + }; + } + + 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..cf71b04a6a 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,31 @@ export class DialogService { return this.openUpsertPrivateSegmentListModal(commonModalConfig); } + openEditConditionWeightsModal(conditions: ExperimentCondition[]): Observable { + const dialogRef = this.dialog.open(EditConditionWeightsModalComponent, { + panelClass: ['experiment-modal', 'modal-shadow'], + hasBackdrop: true, + 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..a92bba7030 100644 --- a/frontend/projects/upgrade/src/assets/i18n/en.json +++ b/frontend/projects/upgrade/src/assets/i18n/en.json @@ -445,6 +445,7 @@ "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", "feature-flags.details-created-on.text": "Created on: ", "feature-flags.details-updated-at.text": "Updated at: ", "feature-flags.global-name.text": "Name", From f5e5443ec24e31c05d4c5e48aa7e913771f3610f Mon Sep 17 00:00:00 2001 From: Ben Blanchard Date: Tue, 9 Sep 2025 16:07:48 -0400 Subject: [PATCH 2/6] Update frontend/projects/upgrade/src/app/features/dashboard/experiments/modals/edit-condition-weights-modal/edit-condition-weights-modal.component.spec.ts Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../edit-condition-weights-modal.component.spec.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) 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 index e62e244038..a35a7fa0dd 100644 --- 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 @@ -23,8 +23,8 @@ describe('EditConditionWeightsModalComponent', () => { cancelBtnLabel: 'Cancel', params: { experimentWeightsArray: [ - { id: '1', conditionCode: 'Control', assignmentWeight: 50 }, - { id: '2', conditionCode: 'Treatment', assignmentWeight: 50 }, + { conditionId: '1', conditionCode: 'Control', assignmentWeight: 50 }, + { conditionId: '2', conditionCode: 'Treatment', assignmentWeight: 50 }, ], }, }; From f1be157a0327326b784518731e18db3044a72920 Mon Sep 17 00:00:00 2001 From: Benjamin Blanchard Date: Tue, 9 Sep 2025 16:33:27 -0400 Subject: [PATCH 3/6] remove inaccurate comment --- .../edit-condition-weights-modal.component.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 index f9b08021cb..b7f0077b32 100644 --- 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 @@ -62,7 +62,7 @@ export class EditConditionWeightsModalComponent implements OnInit { value: 'custom', name: 'Custom Percentages', description: 'Define a custom weight percentage for each condition.', - disabled: false, // Disabled for v2 - will be enabled in future versions + disabled: false, }, ]; From fa0ec28b0f526b03f543b9785f09212408bbd5bc Mon Sep 17 00:00:00 2001 From: Benjamin Blanchard Date: Wed, 10 Sep 2025 10:18:13 -0400 Subject: [PATCH 4/6] tighten css --- .../edit-condition-weights-modal.component.scss | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) 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 index 5cc2c4aa3f..d1b54b9a65 100644 --- 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 @@ -72,6 +72,7 @@ .weight-input { width: 90px; + flex-direction: row; .mat-mdc-form-field-subscript-wrapper { display: none; @@ -102,27 +103,22 @@ } .weight-method-section { - margin-bottom: 24px; - + margin-bottom: 6px; + .section-label { display: block; - margin-bottom: 16px; } mat-radio-group { display: flex; flex-direction: column; - gap: 12px; + gap: 6px; } .radio-content { display: flex; flex-direction: column; - .radio-label { - margin-bottom: 4px; - } - .radio-description { color: var(--dark-grey); } From c164dfbc1cf40db3d031a9e42ca43196b4228ed8 Mon Sep 17 00:00:00 2001 From: Benjamin Blanchard Date: Thu, 11 Sep 2025 17:26:46 -0400 Subject: [PATCH 5/6] changes to get closer to the spec --- ...dit-condition-weights-modal.component.html | 156 ++++++++------- ...dit-condition-weights-modal.component.scss | 75 ++++--- ...-condition-weights-modal.component.spec.ts | 186 ++++++++++++++++-- .../edit-condition-weights-modal.component.ts | 99 +++++++--- .../shared/services/common-dialog.service.ts | 1 + .../projects/upgrade/src/assets/i18n/en.json | 8 + 6 files changed, 380 insertions(+), 145 deletions(-) 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 index 5ea1366c82..11d3000098 100644 --- 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 @@ -9,7 +9,7 @@
- +
- - - - - - +
- Condition - - {{ condition.conditionCode }} -
+ + + + + + + - - - - - + + + + - + + + + + - - - - -
+ 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 (%) - - - - % - - - - Weight is required - - - Weight cannot be negative - - - Weight cannot exceed 100% - - - Maximum 2 decimal places - - - Invalid number - - - + Weight (%) + + + + - -
+ + {{ getCurrentTotal() | number:'1.2-2' }} + +
- No conditions available -
+ + + + + + + + + + + No conditions available + + + - -
- - Total: {{ getTotalWeightStatus().total }}% - - - {{ getTotalWeightStatus().error }} - - - ✓ Weights are balanced - -
\ 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 index d1b54b9a65..3829cbf19f 100644 --- 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 @@ -17,7 +17,19 @@ line-height: 8px; content: '\200C'; } - .conditions-table { + .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); @@ -61,6 +73,39 @@ } } + ::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; @@ -68,11 +113,17 @@ .weight-column { width: 35%; - text-align: center; + 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; @@ -81,26 +132,6 @@ } } } -.weight-summary { - margin-top: 16px; - padding: 12px; - border: 1px solid #e0e0e0; - border-radius: 4px; - text-align: center; - - &.error { - border-color: #f44336; - background-color: #ffebee; - } - - .success-text { - color: #4caf50; - } - - mat-error { - margin: 0; - } -} .weight-method-section { margin-bottom: 6px; 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 index a35a7fa0dd..365142c929 100644 --- 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 @@ -75,8 +75,23 @@ describe('EditConditionWeightsModalComponent', () => { expect(component.conditionWeightForm.get('conditions')).toBeDefined(); }); - it('should initialize with null weighting method', () => { - expect(component.conditionWeightForm.get('weightingMethod')?.value).toBeNull(); + 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', () => { @@ -89,24 +104,111 @@ describe('EditConditionWeightsModalComponent', () => { expect(firstCondition.get('assignmentWeight')?.value).toBe(50); }); - it('should initially disable weight inputs', () => { + 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 when custom method is selected', () => { + 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 when equal method is selected', () => { + 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(); @@ -116,6 +218,7 @@ describe('EditConditionWeightsModalComponent', () => { 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', () => { @@ -134,9 +237,9 @@ describe('EditConditionWeightsModalComponent', () => { ...mockDialogData, params: { experimentWeightsArray: [ - { id: '1', conditionCode: 'A', assignmentWeight: 0 }, - { id: '2', conditionCode: 'B', assignmentWeight: 0 }, - { id: '3', conditionCode: 'C', assignmentWeight: 0 }, + { conditionId: '1', conditionCode: 'A', assignmentWeight: 0 }, + { conditionId: '2', conditionCode: 'B', assignmentWeight: 0 }, + { conditionId: '3', conditionCode: 'C', assignmentWeight: 0 }, ], }, }; @@ -219,24 +322,30 @@ describe('EditConditionWeightsModalComponent', () => { component.getWeightControl(1).setValue(50); const status = component.getTotalWeightStatus(); - expect(status.total).toBe(100); - expect(status.isValid).toBe(true); - expect(status.error).toBeUndefined(); + 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.total).toBe(90); - expect(status.isValid).toBe(false); - expect(status.error).toContain('Total must equal 100%'); + 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 invalid', (done) => { + 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 @@ -247,7 +356,9 @@ describe('EditConditionWeightsModalComponent', () => { }); }); - it('should be enabled when form is valid', (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) => { @@ -255,11 +366,25 @@ describe('EditConditionWeightsModalComponent', () => { 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( @@ -279,7 +404,11 @@ describe('EditConditionWeightsModalComponent', () => { }); it('should not close dialog when form is invalid', () => { - // Form is invalid because no weighting method is selected + // 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(); @@ -344,4 +473,27 @@ describe('EditConditionWeightsModalComponent', () => { 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 index b7f0077b32..9eb23a44b5 100644 --- 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 @@ -16,7 +16,7 @@ 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 } from '@ngx-translate/core'; +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'; @@ -39,7 +39,6 @@ export interface ConditionWeightUpdate { CommonModule, ReactiveFormsModule, TranslateModule, - NgIf, ], templateUrl: './edit-condition-weights-modal.component.html', styleUrl: './edit-condition-weights-modal.component.scss', @@ -54,14 +53,18 @@ export class EditConditionWeightsModalComponent implements OnInit { weightingMethods = [ { value: 'equal', - name: 'Weight Equally', - description: 'Equally distribute weight percentages across all conditions.', + 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: 'Custom Percentages', - description: 'Define a custom weight percentage for each condition.', + 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, }, ]; @@ -71,6 +74,7 @@ export class EditConditionWeightsModalComponent implements OnInit { public config: CommonModalConfig<{ experimentWeightsArray: ConditionWeightUpdate[] }>, public dialog: MatDialog, private readonly formBuilder: FormBuilder, + private translate: TranslateService, public dialogRef: MatDialogRef ) {} @@ -82,6 +86,9 @@ export class EditConditionWeightsModalComponent implements OnInit { 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) => @@ -97,7 +104,7 @@ export class EditConditionWeightsModalComponent implements OnInit { ); this.conditionWeightForm = this.formBuilder.group({ - weightingMethod: [null, Validators.required], + weightingMethod: [initialWeightingMethod, Validators.required], conditions: conditionsFormArray, }); @@ -107,8 +114,37 @@ export class EditConditionWeightsModalComponent implements OnInit { // Watch for weighting method changes this.watchWeightingMethodChanges(); - // Initially disable weight inputs until method is selected - this.disableWeightInputs(); + // 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 { @@ -117,20 +153,25 @@ export class EditConditionWeightsModalComponent implements OnInit { this.conditionWeightForm.valueChanges.pipe(startWith(this.conditionWeightForm.value)), ]).pipe( map(([status, value]) => { - return status === 'INVALID' || this.conditionsFormArray.hasError('totalWeightInvalid'); + 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) { - // No method selected - disable inputs this.disableWeightInputs(); } }); @@ -156,28 +197,33 @@ export class EditConditionWeightsModalComponent implements OnInit { return null; } - // Array-level validator to ensure total weight equals 100 + // Array-level validator for total weight and individual control errors totalWeightValidator(formArray: AbstractControl): ValidationErrors | null { if (!(formArray instanceof FormArray)) { return null; } - const total = formArray.controls.reduce((sum, control) => { - const weight = control.get('assignmentWeight')?.value; - return sum + (parseFloat(weight) || 0); - }, 0); - - // Allow for small rounding errors (within 0.01) + 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; - - return isValid - ? null + 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 { @@ -228,15 +274,8 @@ export class EditConditionWeightsModalComponent implements OnInit { } // Helper method to get total weight status for display - getTotalWeightStatus(): { total: number; isValid: boolean; error?: string } { - const total = this.getCurrentTotal(); - const isValid = Math.abs(total - 100) < 0.01; - - return { - total: Math.round(total * 100) / 100, - isValid, - error: !isValid ? `Total must equal 100% (current: ${Math.round(total * 100) / 100}%)` : undefined, - }; + getTotalWeightStatus(): ValidationErrors { + return this.conditionsFormArray.errors as ValidationErrors; } onPrimaryActionBtnClicked() { 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 cf71b04a6a..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 @@ -372,6 +372,7 @@ export class DialogService { const dialogRef = this.dialog.open(EditConditionWeightsModalComponent, { panelClass: ['experiment-modal', 'modal-shadow'], hasBackdrop: true, + autoFocus: false, backdropClass: 'modal-backdrop', width: ModalSize.STANDARD, diff --git a/frontend/projects/upgrade/src/assets/i18n/en.json b/frontend/projects/upgrade/src/assets/i18n/en.json index a92bba7030..f636554308 100644 --- a/frontend/projects/upgrade/src/assets/i18n/en.json +++ b/frontend/projects/upgrade/src/assets/i18n/en.json @@ -446,6 +446,14 @@ "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", From 767c36cb009663319d531d3522dcaa9c73edca71 Mon Sep 17 00:00:00 2001 From: Benjamin Blanchard Date: Tue, 16 Sep 2025 14:10:41 -0400 Subject: [PATCH 6/6] remove unneeded things --- .../edit-condition-weights-modal.component.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) 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 index 9eb23a44b5..898d9ffa0e 100644 --- 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 @@ -10,7 +10,7 @@ import { AbstractControl, ValidationErrors, } from '@angular/forms'; -import { NgIf, CommonModule } from '@angular/common'; +import { CommonModule } from '@angular/common'; import { MatFormFieldModule } from '@angular/material/form-field'; import { MatInputModule } from '@angular/material/input'; import { MatButtonModule } from '@angular/material/button'; @@ -152,7 +152,7 @@ export class EditConditionWeightsModalComponent implements OnInit { this.conditionWeightForm.statusChanges.pipe(startWith(this.conditionWeightForm.status)), this.conditionWeightForm.valueChanges.pipe(startWith(this.conditionWeightForm.value)), ]).pipe( - map(([status, value]) => { + map(([status]) => { return ( status === 'INVALID' || this.conditionsFormArray.hasError('totalWeightInvalid') || @@ -275,7 +275,7 @@ export class EditConditionWeightsModalComponent implements OnInit { // Helper method to get total weight status for display getTotalWeightStatus(): ValidationErrors { - return this.conditionsFormArray.errors as ValidationErrors; + return this.conditionsFormArray.errors; } onPrimaryActionBtnClicked() {