diff --git a/backend/packages/Upgrade/src/api/middlewares/ErrorHandlerMiddleware.ts b/backend/packages/Upgrade/src/api/middlewares/ErrorHandlerMiddleware.ts index 522312d553..a5c5756166 100644 --- a/backend/packages/Upgrade/src/api/middlewares/ErrorHandlerMiddleware.ts +++ b/backend/packages/Upgrade/src/api/middlewares/ErrorHandlerMiddleware.ts @@ -97,6 +97,10 @@ export class ErrorHandlerMiddleware implements ExpressErrorMiddlewareInterface { message = error.message; type = SERVER_ERROR.QUERY_FAILED; break; + case 409: + message = error.message; + type = SERVER_ERROR.DUPLICATE_KEY; + break; case 422: message = error.message; type = SERVER_ERROR.UNSUPPORTED_CALIPER; diff --git a/backend/packages/Upgrade/src/api/models/FeatureFlag.ts b/backend/packages/Upgrade/src/api/models/FeatureFlag.ts index 5ac0b1b7f1..25ecadb159 100644 --- a/backend/packages/Upgrade/src/api/models/FeatureFlag.ts +++ b/backend/packages/Upgrade/src/api/models/FeatureFlag.ts @@ -1,4 +1,4 @@ -import { Column, Entity, PrimaryColumn, OneToMany } from 'typeorm'; +import { Column, Entity, PrimaryColumn, OneToMany, Unique } from 'typeorm'; import { IsNotEmpty } from 'class-validator'; import { BaseModel } from './base/BaseModel'; import { Type } from 'class-transformer'; @@ -7,6 +7,7 @@ import { FeatureFlagSegmentInclusion } from './FeatureFlagSegmentInclusion'; import { FeatureFlagSegmentExclusion } from './FeatureFlagSegmentExclusion'; import { FeatureFlagExposure } from './FeatureFlagExposure'; @Entity() +@Unique(['key', 'context']) export class FeatureFlag extends BaseModel { @PrimaryColumn('uuid') public id: string; @@ -15,7 +16,7 @@ export class FeatureFlag extends BaseModel { @Column() public name: string; - @Column('text', { unique: true }) + @Column('text') public key: string; @Column() diff --git a/backend/packages/Upgrade/src/api/repositories/FeatureFlagRepository.ts b/backend/packages/Upgrade/src/api/repositories/FeatureFlagRepository.ts index 73e82d91a1..0f6da24de1 100644 --- a/backend/packages/Upgrade/src/api/repositories/FeatureFlagRepository.ts +++ b/backend/packages/Upgrade/src/api/repositories/FeatureFlagRepository.ts @@ -3,6 +3,7 @@ import { EntityRepository } from '../../typeorm-typedi-extensions'; import { FeatureFlag } from '../models/FeatureFlag'; import repositoryError from './utils/repositoryError'; import { FEATURE_FLAG_STATUS, FILTER_MODE } from 'upgrade_types'; +import { FeatureFlagValidation } from '../controllers/validators/FeatureFlagValidator'; @EntityRepository(FeatureFlag) export class FeatureFlagRepository extends Repository { @@ -111,4 +112,17 @@ export class FeatureFlagRepository extends Repository { return result; } + + public async validateUniqueKey(flagDTO: FeatureFlagValidation) { + const queryBuilder = this.createQueryBuilder('feature_flag') + .where('feature_flag.key = :key', { key: flagDTO.key }) + .andWhere('feature_flag.context = :context', { context: flagDTO.context }); + + if (flagDTO.id) { + queryBuilder.andWhere('feature_flag.id != :id', { id: flagDTO.id }); + } + + const result = await queryBuilder.getOne(); + return result; + } } diff --git a/backend/packages/Upgrade/src/api/services/FeatureFlagService.ts b/backend/packages/Upgrade/src/api/services/FeatureFlagService.ts index 6c2034634c..cf6775fadf 100644 --- a/backend/packages/Upgrade/src/api/services/FeatureFlagService.ts +++ b/backend/packages/Upgrade/src/api/services/FeatureFlagService.ts @@ -142,8 +142,17 @@ export class FeatureFlagService { return featureFlag; } - public create(flagDTO: FeatureFlagValidation, currentUser: User, logger: UpgradeLogger): Promise { + public async create(flagDTO: FeatureFlagValidation, currentUser: User, logger: UpgradeLogger): Promise { logger.info({ message: 'Create a new feature flag', details: flagDTO }); + const result = await this.featureFlagRepository.validateUniqueKey(flagDTO); + + if (result) { + const error = new Error(`A flag with this key already exists for this app-context`); + (error as any).type = SERVER_ERROR.DUPLICATE_KEY; + (error as any).httpCode = 409; + throw error; + } + return this.addFeatureFlagInDB(this.featureFlagValidatorToFlag(flagDTO), currentUser, logger); } @@ -295,8 +304,16 @@ export class FeatureFlagService { return featureFlag; } - public update(flagDTO: FeatureFlagValidation, currentUser: User, logger: UpgradeLogger): Promise { + public async update(flagDTO: FeatureFlagValidation, currentUser: User, logger: UpgradeLogger): Promise { logger.info({ message: `Update a Feature Flag => ${flagDTO.toString()}` }); + const result = await this.featureFlagRepository.validateUniqueKey(flagDTO); + + if (result) { + const error = new Error(`A flag with this key already exists for this app-context`); + (error as any).type = SERVER_ERROR.DUPLICATE_KEY; + (error as any).httpCode = 409; + throw error; + } // TODO add entry in log of updating feature flag return this.updateFeatureFlagInDB(this.featureFlagValidatorToFlag(flagDTO), currentUser, logger); } @@ -902,7 +919,9 @@ export class FeatureFlagService { } if (compatibilityType === FF_COMPATIBILITY_TYPE.COMPATIBLE) { - const keyExists = existingFeatureFlags.find((existingFlag) => existingFlag.key === flag.key); + const keyExists = existingFeatureFlags?.find( + (existingFlag) => existingFlag.key === flag.key && existingFlag.context === flag.context + ); if (keyExists) { compatibilityType = FF_COMPATIBILITY_TYPE.INCOMPATIBLE; diff --git a/backend/packages/Upgrade/src/database/migrations/1721969267641-UniquePairFlag.ts b/backend/packages/Upgrade/src/database/migrations/1721969267641-UniquePairFlag.ts new file mode 100644 index 0000000000..4f7587e8a8 --- /dev/null +++ b/backend/packages/Upgrade/src/database/migrations/1721969267641-UniquePairFlag.ts @@ -0,0 +1,43 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class UniquePairFlag1721969267641 implements MigrationInterface { + name = 'UniquePairFlag1721969267641'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "feature_flag_segment_exclusion" ALTER COLUMN "enabled" SET DEFAULT false`); + await queryRunner.query(`ALTER TABLE "feature_flag" DROP CONSTRAINT "UQ_960310efa932f7a29eec57350b3"`); + await queryRunner.query(`ALTER TABLE "feature_flag_segment_inclusion" ALTER COLUMN "enabled" SET DEFAULT false`); + await queryRunner.query( + `ALTER TYPE "public"."experiment_error_type_enum" RENAME TO "experiment_error_type_enum_old"` + ); + await queryRunner.query( + `CREATE TYPE "public"."experiment_error_type_enum" AS ENUM('Database not reachable', 'Database auth fail', 'Error in the assignment algorithm', 'Parameter missing in the client request', 'Parameter not in the correct format', 'User ID not found', 'Query Failed', 'Error reported from client', 'Experiment user not defined', 'Experiment user group not defined', 'Working group is not a subset of user group', 'Invalid token', 'Token is not present in request', 'JWT Token validation failed', 'Error in migration', 'Email send error', 'Condition not found', 'Experiment ID not provided for shared Decision Point', 'Experiment ID provided is invalid for shared Decision Point', 'Caliper profile or event not supported', 'Feature Flag with same key already exists for this app-context')` + ); + await queryRunner.query( + `ALTER TABLE "experiment_error" ALTER COLUMN "type" TYPE "public"."experiment_error_type_enum" USING "type"::"text"::"public"."experiment_error_type_enum"` + ); + await queryRunner.query(`DROP TYPE "public"."experiment_error_type_enum_old"`); + await queryRunner.query( + `ALTER TABLE "feature_flag" ADD CONSTRAINT "UQ_653d98ebae725596b4052f80a65" UNIQUE ("key", "context")` + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE "feature_flag" DROP CONSTRAINT "UQ_653d98ebae725596b4052f80a65"`); + await queryRunner.query( + `CREATE TYPE "public"."experiment_error_type_enum_old" AS ENUM('Caliper profile or event not supported', 'Condition not found', 'Database auth fail', 'Database not reachable', 'Email send error', 'Error in migration', 'Error in the assignment algorithm', 'Error reported from client', 'Experiment ID not provided for shared Decision Point', 'Experiment ID provided is invalid for shared Decision Point', 'Experiment user group not defined', 'Experiment user not defined', 'Invalid token', 'JWT Token validation failed', 'Parameter missing in the client request', 'Parameter not in the correct format', 'Query Failed', 'Token is not present in request', 'User ID not found', 'Working group is not a subset of user group')` + ); + await queryRunner.query( + `ALTER TABLE "experiment_error" ALTER COLUMN "type" TYPE "public"."experiment_error_type_enum_old" USING "type"::"text"::"public"."experiment_error_type_enum_old"` + ); + await queryRunner.query(`DROP TYPE "public"."experiment_error_type_enum"`); + await queryRunner.query( + `ALTER TYPE "public"."experiment_error_type_enum_old" RENAME TO "experiment_error_type_enum"` + ); + await queryRunner.query(`ALTER TABLE "feature_flag_segment_inclusion" ALTER COLUMN "enabled" DROP DEFAULT`); + await queryRunner.query( + `ALTER TABLE "feature_flag" ADD CONSTRAINT "UQ_960310efa932f7a29eec57350b3" UNIQUE ("key")` + ); + await queryRunner.query(`ALTER TABLE "feature_flag_segment_exclusion" ALTER COLUMN "enabled" DROP DEFAULT`); + } +} diff --git a/backend/packages/Upgrade/test/unit/services/FeatureFlagService.test.ts b/backend/packages/Upgrade/test/unit/services/FeatureFlagService.test.ts index c63124cf90..dcfad010a1 100644 --- a/backend/packages/Upgrade/test/unit/services/FeatureFlagService.test.ts +++ b/backend/packages/Upgrade/test/unit/services/FeatureFlagService.test.ts @@ -194,6 +194,7 @@ describe('Feature Flag Service Testing', () => { addOrderBy: addOrderBySpy, setParameter: setParameterSpy, where: jest.fn().mockReturnThis(), + andWhere: jest.fn().mockReturnThis(), offset: offsetSpy, limit: limitSpy, innerJoinAndSelect: jest.fn().mockReturnThis(), @@ -202,6 +203,7 @@ describe('Feature Flag Service Testing', () => { getMany: jest.fn().mockResolvedValue(mockFlagArr), getOne: jest.fn().mockResolvedValue(mockFlag1), })), + validateUniqueKey: jest.fn().mockResolvedValue(null), }, }, { diff --git a/frontend/projects/upgrade/src/app/core/feature-flags/feature-flags.service.ts b/frontend/projects/upgrade/src/app/core/feature-flags/feature-flags.service.ts index d0fe315758..2db3dec2ce 100644 --- a/frontend/projects/upgrade/src/app/core/feature-flags/feature-flags.service.ts +++ b/frontend/projects/upgrade/src/app/core/feature-flags/feature-flags.service.ts @@ -26,6 +26,7 @@ import { selectFeatureFlagIds, selectShouldShowWarningForSelectedFlag, selectWarningStatusForAllFlags, + selectDuplicateKeyFound, } from './store/feature-flags.selectors'; import * as FeatureFlagsActions from './store/feature-flags.actions'; import { actionFetchContextMetaData } from '../experiments/store/experiments.actions'; @@ -51,6 +52,7 @@ export class FeatureFlagsService { isLoadingFeatureFlags$ = this.store$.pipe(select(selectIsLoadingFeatureFlags)); isLoadingSelectedFeatureFlag$ = this.store$.pipe(select(selectIsLoadingSelectedFeatureFlag)); isLoadingUpsertFeatureFlag$ = this.store$.pipe(select(selectIsLoadingUpsertFeatureFlag)); + isDuplicateKeyFound$ = this.store$.pipe(select(selectDuplicateKeyFound)); isLoadingFeatureFlagDelete$ = this.store$.pipe(select(selectIsLoadingFeatureFlagDelete)); isLoadingImportFeatureFlag$ = this.store$.pipe(select(selectIsLoadingImportFeatureFlag)); isLoadingUpdateFeatureFlagStatus$ = this.store$.pipe(select(selectIsLoadingUpdateFeatureFlagStatus)); @@ -140,6 +142,10 @@ export class FeatureFlagsService { this.store$.dispatch(FeatureFlagsActions.actionUpdateFilterMode({ updateFilterModeRequest })); } + setIsDuplicateKey(duplicateKeyFound: boolean) { + this.store$.dispatch(FeatureFlagsActions.actionSetIsDuplicateKey({ duplicateKeyFound })); + } + deleteFeatureFlag(flagId: string) { this.store$.dispatch(FeatureFlagsActions.actionDeleteFeatureFlag({ flagId })); } diff --git a/frontend/projects/upgrade/src/app/core/feature-flags/store/feature-flags.actions.ts b/frontend/projects/upgrade/src/app/core/feature-flags/store/feature-flags.actions.ts index ce4d86cee1..ab00140bbb 100644 --- a/frontend/projects/upgrade/src/app/core/feature-flags/store/feature-flags.actions.ts +++ b/frontend/projects/upgrade/src/app/core/feature-flags/store/feature-flags.actions.ts @@ -45,6 +45,11 @@ export const actionAddFeatureFlagSuccess = createAction( export const actionAddFeatureFlagFailure = createAction('[Feature Flags] Add Feature Flag Failure'); +export const actionSetIsDuplicateKey = createAction( + '[Feature Flags] Upsert Feature Flag Failure, Duplicate Key found', + props<{ duplicateKeyFound: boolean }>() +); + export const actionDeleteFeatureFlag = createAction('[Feature Flags] Delete Feature Flag', props<{ flagId: string }>()); export const actionDeleteFeatureFlagSuccess = createAction( diff --git a/frontend/projects/upgrade/src/app/core/feature-flags/store/feature-flags.effects.ts b/frontend/projects/upgrade/src/app/core/feature-flags/store/feature-flags.effects.ts index 38ad6f96ee..a0894d9ee8 100644 --- a/frontend/projects/upgrade/src/app/core/feature-flags/store/feature-flags.effects.ts +++ b/frontend/projects/upgrade/src/app/core/feature-flags/store/feature-flags.effects.ts @@ -15,12 +15,11 @@ import { selectSortAs, selectSearchString, selectIsAllFlagsFetched, - selectSelectedFeatureFlag, } from './feature-flags.selectors'; import { selectCurrentUser } from '../../auth/store/auth.selectors'; import { CommonExportHelpersService } from '../../../shared/services/common-export-helpers.service'; import { of } from 'rxjs'; - +import { SERVER_ERROR } from 'upgrade_types'; @Injectable() export class FeatureFlagsEffects { constructor( @@ -103,7 +102,12 @@ export class FeatureFlagsEffects { tap(({ response }) => { this.router.navigate(['/featureflags', 'detail', response.id]); }), - catchError(() => [FeatureFlagsActions.actionAddFeatureFlagFailure()]) + catchError((res) => { + if (res.error.type == SERVER_ERROR.DUPLICATE_KEY) { + return [FeatureFlagsActions.actionSetIsDuplicateKey({ duplicateKeyFound: true })]; + } + return [FeatureFlagsActions.actionAddFeatureFlagFailure()]; + }) ); }) ) @@ -117,7 +121,12 @@ export class FeatureFlagsEffects { map((response) => { return FeatureFlagsActions.actionUpdateFeatureFlagSuccess({ response }); }), - catchError(() => [FeatureFlagsActions.actionUpdateFeatureFlagFailure()]) + catchError((res) => { + if (res.error.type == SERVER_ERROR.DUPLICATE_KEY) { + return [FeatureFlagsActions.actionSetIsDuplicateKey({ duplicateKeyFound: true })]; + } + return [FeatureFlagsActions.actionAddFeatureFlagFailure()]; + }) ); }) ) diff --git a/frontend/projects/upgrade/src/app/core/feature-flags/store/feature-flags.model.ts b/frontend/projects/upgrade/src/app/core/feature-flags/store/feature-flags.model.ts index 03de3b2436..88529df35d 100644 --- a/frontend/projects/upgrade/src/app/core/feature-flags/store/feature-flags.model.ts +++ b/frontend/projects/upgrade/src/app/core/feature-flags/store/feature-flags.model.ts @@ -186,6 +186,7 @@ export interface FeatureFlagState extends EntityState { isLoadingFeatureFlagDelete: boolean; isLoadingUpsertPrivateSegmentList: boolean; hasInitialFeatureFlagsDataLoaded: boolean; + duplicateKeyFound: boolean; activeDetailsTabIndex: number; skipFlags: number; totalFlags: number; diff --git a/frontend/projects/upgrade/src/app/core/feature-flags/store/feature-flags.reducer.ts b/frontend/projects/upgrade/src/app/core/feature-flags/store/feature-flags.reducer.ts index ec79707993..570258b1c0 100644 --- a/frontend/projects/upgrade/src/app/core/feature-flags/store/feature-flags.reducer.ts +++ b/frontend/projects/upgrade/src/app/core/feature-flags/store/feature-flags.reducer.ts @@ -20,6 +20,7 @@ export const initialState: FeatureFlagState = adapter.getInitialState({ isLoadingSelectedFeatureFlag: false, isLoadingUpsertPrivateSegmentList: false, hasInitialFeatureFlagsDataLoaded: false, + duplicateKeyFound: false, activeDetailsTabIndex: 0, skipFlags: 0, totalFlags: null, @@ -75,6 +76,11 @@ const reducer = createReducer( ...state, isLoadingUpsertFeatureFlag: false, })), + on(FeatureFlagsActions.actionSetIsDuplicateKey, (state, { duplicateKeyFound }) => ({ + ...state, + duplicateKeyFound, + isLoadingUpsertFeatureFlag: false, + })), // Feature Flag Delete Actions on(FeatureFlagsActions.actionDeleteFeatureFlag, (state) => ({ ...state, isLoadingFeatureFlagDelete: true })), diff --git a/frontend/projects/upgrade/src/app/core/feature-flags/store/feature-flags.selectors.ts b/frontend/projects/upgrade/src/app/core/feature-flags/store/feature-flags.selectors.ts index c5f51fb1bd..770cb9e0ed 100644 --- a/frontend/projects/upgrade/src/app/core/feature-flags/store/feature-flags.selectors.ts +++ b/frontend/projects/upgrade/src/app/core/feature-flags/store/feature-flags.selectors.ts @@ -47,6 +47,8 @@ export const selectIsLoadingUpsertFeatureFlag = createSelector( (state) => state.isLoadingUpsertFeatureFlag ); +export const selectDuplicateKeyFound = createSelector(selectFeatureFlagsState, (state) => state.duplicateKeyFound); + export const selectSelectedFeatureFlag = createSelector( selectRouterState, selectFeatureFlagsState, diff --git a/frontend/projects/upgrade/src/app/core/http-interceptors/http-error.interceptor.ts b/frontend/projects/upgrade/src/app/core/http-interceptors/http-error.interceptor.ts index 78915b0748..582254113e 100755 --- a/frontend/projects/upgrade/src/app/core/http-interceptors/http-error.interceptor.ts +++ b/frontend/projects/upgrade/src/app/core/http-interceptors/http-error.interceptor.ts @@ -5,6 +5,7 @@ import { Observable, throwError } from 'rxjs'; import { catchError } from 'rxjs/operators'; import { ENV, Environment } from '../../../environments/environment-types'; import { AuthService } from '../auth/auth.service'; +import { SERVER_ERROR } from 'upgrade_types'; @Injectable() export class HttpErrorInterceptor implements HttpInterceptor { @@ -21,7 +22,9 @@ export class HttpErrorInterceptor implements HttpInterceptor { content: error.url, animate: 'fromRight', }; - this._notifications.create(temp.title, temp.content, temp.type, temp); + if (error.error?.type !== SERVER_ERROR.DUPLICATE_KEY) { + this._notifications.create(temp.title, temp.content, temp.type, temp); + } } intercept(request: HttpRequest, next: HttpHandler): Observable> { diff --git a/frontend/projects/upgrade/src/app/features/dashboard/feature-flags/modals/upsert-feature-flag-modal/upsert-feature-flag-modal.component.html b/frontend/projects/upgrade/src/app/features/dashboard/feature-flags/modals/upsert-feature-flag-modal/upsert-feature-flag-modal.component.html index f62e5ae69c..f1a94431a7 100644 --- a/frontend/projects/upgrade/src/app/features/dashboard/feature-flags/modals/upsert-feature-flag-modal/upsert-feature-flag-modal.component.html +++ b/frontend/projects/upgrade/src/app/features/dashboard/feature-flags/modals/upsert-feature-flag-modal/upsert-feature-flag-modal.component.html @@ -5,38 +5,53 @@ [primaryActionBtnColor]="config.primaryActionBtnColor" [primaryActionBtnDisabled]="isPrimaryButtonDisabled$ | async" (primaryActionBtnClicked)="onPrimaryActionBtnClicked()" - > +>
Name - + - {{'feature-flags.upsert-flag-modal.name-hint.text' | translate}} + {{ 'feature-flags.upsert-flag-modal.name-hint.text' | translate }} Key - + - {{'feature-flags.upsert-flag-modal.key-hint.text' | translate}} - Learn more + + + {{ 'feature-flags.upsert-flag-modal.key-hint.text' | translate }} + Learn more + + + + + {{ 'feature-flags.upsert-flag-modal.duplicate-key-error.text' | translate }} + + Description (optional) - + App Context - {{ context }} + {{ + context + }} - {{ 'feature-flags.upsert-flag-modal.app-context-hint.text' | translate}} - Learn more + {{ 'feature-flags.upsert-flag-modal.app-context-hint.text' | translate }} + Learn more diff --git a/frontend/projects/upgrade/src/app/features/dashboard/feature-flags/modals/upsert-feature-flag-modal/upsert-feature-flag-modal.component.scss b/frontend/projects/upgrade/src/app/features/dashboard/feature-flags/modals/upsert-feature-flag-modal/upsert-feature-flag-modal.component.scss index e69de29bb2..ef4395adac 100644 --- a/frontend/projects/upgrade/src/app/features/dashboard/feature-flags/modals/upsert-feature-flag-modal/upsert-feature-flag-modal.component.scss +++ b/frontend/projects/upgrade/src/app/features/dashboard/feature-flags/modals/upsert-feature-flag-modal/upsert-feature-flag-modal.component.scss @@ -0,0 +1,3 @@ +.duplicate-error-message { + color: var(--red); +} \ No newline at end of file diff --git a/frontend/projects/upgrade/src/app/features/dashboard/feature-flags/modals/upsert-feature-flag-modal/upsert-feature-flag-modal.component.ts b/frontend/projects/upgrade/src/app/features/dashboard/feature-flags/modals/upsert-feature-flag-modal/upsert-feature-flag-modal.component.ts index e48ae26312..a1a2c09399 100644 --- a/frontend/projects/upgrade/src/app/features/dashboard/feature-flags/modals/upsert-feature-flag-modal/upsert-feature-flag-modal.component.ts +++ b/frontend/projects/upgrade/src/app/features/dashboard/feature-flags/modals/upsert-feature-flag-modal/upsert-feature-flag-modal.component.ts @@ -69,6 +69,7 @@ export class UpsertFeatureFlagModalComponent { isSelectedFeatureFlagUpdated$ = this.featureFlagsService.isSelectedFeatureFlagUpdated$; selectedFlag$ = this.featureFlagsService.selectedFeatureFlag$; appContexts$ = this.featureFlagsService.appContexts$; + isDuplicateKeyFound$ = this.featureFlagsService.isDuplicateKeyFound$; subscriptions = new Subscription(); isInitialFormValueChanged$: Observable; @@ -77,6 +78,7 @@ export class UpsertFeatureFlagModalComponent { initialFormValues$ = new BehaviorSubject(null); featureFlagForm: FormGroup; + validationError = false; CommonTagInputType = CommonTagInputType; constructor( @@ -90,12 +92,16 @@ export class UpsertFeatureFlagModalComponent { ) {} ngOnInit(): void { + this.featureFlagsService.setIsDuplicateKey(false); this.experimentService.fetchContextMetaData(); this.createFeatureFlagForm(); + this.listenOnKeyChangesToRemoveWarning(); this.listenForFeatureFlagGetUpdated(); this.listenOnNameChangesToUpdateKey(); this.listenForIsInitialFormValueChanged(); this.listenForPrimaryButtonDisabled(); + this.listenForDuplicateKey(); + this.listenOnContext(); } createFeatureFlagForm(): void { @@ -134,6 +140,24 @@ export class UpsertFeatureFlagModalComponent { ); } + listenOnKeyChangesToRemoveWarning(): void { + this.subscriptions.add( + this.featureFlagForm.get('key')?.valueChanges.subscribe((key) => { + this.validationError = this.validationError ? false : this.validationError; + this.featureFlagsService.setIsDuplicateKey(false); + key ? this.featureFlagForm.get('key').setErrors(null) : null; + }) + ); + } + + listenOnContext(): void { + this.subscriptions.add( + this.featureFlagForm.get('appContext')?.valueChanges.subscribe(() => { + this.featureFlagForm.get('key') ? this.featureFlagForm.get('key').setErrors(null) : null; + }) + ); + } + listenForIsInitialFormValueChanged() { this.isInitialFormValueChanged$ = this.featureFlagForm.valueChanges.pipe( startWith(this.featureFlagForm.value), @@ -152,12 +176,25 @@ export class UpsertFeatureFlagModalComponent { // Close the modal once the feature flag list length changes, as that indicates actual success listenForFeatureFlagGetUpdated(): void { - this.subscriptions.add(this.isSelectedFeatureFlagUpdated$.subscribe(() => this.closeModal())); + this.subscriptions.add( + this.isSelectedFeatureFlagUpdated$.subscribe(() => { + this.closeModal(); + }) + ); + } + + listenForDuplicateKey() { + this.subscriptions.add( + this.isDuplicateKeyFound$.subscribe((isDuplicate) => { + this.validationError = isDuplicate; + this.featureFlagForm.get('key').setErrors({ duplicateKey: isDuplicate }); + isDuplicate ? this.featureFlagForm.get('key').markAllAsTouched() : null; + }) + ); } - onPrimaryActionBtnClicked(): void { + onPrimaryActionBtnClicked() { if (this.featureFlagForm.valid) { - // Handle extra frontend form validation logic here? this.sendRequest(this.config.params.action, this.config.params.sourceFlag); } else { // If the form is invalid, manually mark all form controls as touched diff --git a/frontend/projects/upgrade/src/assets/i18n/en.json b/frontend/projects/upgrade/src/assets/i18n/en.json index eee91b5594..1abfb32d7a 100644 --- a/frontend/projects/upgrade/src/assets/i18n/en.json +++ b/frontend/projects/upgrade/src/assets/i18n/en.json @@ -373,6 +373,7 @@ "feature-flags.import-flag-modal.compatibility-description.warning.text": "This JSON file can be imported, but it may contain outdated or missing properties/features. Please review the feature flag details post-import.", "feature-flags.upsert-flag-modal.name-hint.text": "The name for this feature flag.", "feature-flags.upsert-flag-modal.key-hint.text": "A unique key used to retrieve this feature flag from the client application.", + "feature-flags.upsert-flag-modal.duplicate-key-error.text": "Feature flag with this key already exists for this app-context.", "feature-flags.upsert-flag-modal.app-context-hint.text": "The App Context indicates where the feature flag will run, known to UpGrade.", "feature-flags.upsert-flag-modal.tags-label.text": "Tags (optional)", "feature-flags.upsert-flag-modal.tags-placeholder.text": "Tags separated by commas", diff --git a/types/src/Experiment/enums.ts b/types/src/Experiment/enums.ts index 6697e306e4..de986a656e 100644 --- a/types/src/Experiment/enums.ts +++ b/types/src/Experiment/enums.ts @@ -65,6 +65,7 @@ export enum SERVER_ERROR { EXPERIMENT_ID_MISSING_FOR_SHARED_DECISIONPOINT = 'Experiment ID not provided for shared Decision Point', INVALID_EXPERIMENT_ID_FOR_SHARED_DECISIONPOINT = 'Experiment ID provided is invalid for shared Decision Point', UNSUPPORTED_CALIPER = 'Caliper profile or event not supported', + DUPLICATE_KEY = 'Feature Flag with same key already exists for this app-context', } export enum MARKED_DECISION_POINT_STATUS {