From 75aa79ba5279d05affa87ae6f8f3ed836bed32f9 Mon Sep 17 00:00:00 2001 From: Zack Lee Date: Tue, 4 Aug 2026 12:12:05 -0400 Subject: [PATCH 01/33] Add list details page for managing include and exclude list values --- .../api/repositories/ExperimentRepository.ts | 392 ++++++++----- .../repositories/ExperimentRepository.test.ts | 108 ++-- .../experiments/store/experiments.effects.ts | 22 + .../store/feature-flags.effects.ts | 23 +- .../list-details.data.service.spec.ts | 141 +++++ .../segments/list-details.data.service.ts | 125 ++++ .../core/segments/list-values.utils.spec.ts | 58 ++ .../app/core/segments/list-values.utils.ts | 64 ++ .../core/segments/store/segments.effects.ts | 20 +- .../app/core/segments/store/segments.model.ts | 14 + .../dashboard/dashboard-routing.module.ts | 32 + ...experiment-inclusions-table.component.html | 1 + .../experiment-inclusions-table.component.ts | 1 + .../edit-list-value-modal.component.html | 20 + .../edit-list-value-modal.component.ts | 43 ++ .../upsert-list-values-modal.component.html | 60 ++ .../upsert-list-values-modal.component.scss | 67 +++ .../upsert-list-values-modal.component.ts | 137 +++++ ...-private-segment-list-modal.component.html | 9 - ...rt-private-segment-list-modal.component.ts | 48 +- .../list-details-page.component.html | 123 ++++ .../list-details-page.component.scss | 92 +++ .../list-details-page.component.ts | 550 ++++++++++++++++++ .../common-details-page-header.component.html | 4 +- .../common-details-page-header.component.ts | 2 + ...ails-participant-list-table.component.html | 17 + ...etails-participant-list-table.component.ts | 9 + .../common-import-container.component.html | 2 +- .../common-import-container.component.ts | 2 + ...section-card-action-buttons.component.html | 4 +- ...-section-card-search-header.component.html | 4 +- ...on-section-card-search-header.component.ts | 2 + ...n-section-card-title-header.component.html | 7 +- packages/types/src/Experiment/interfaces.ts | 1 + 34 files changed, 1973 insertions(+), 231 deletions(-) create mode 100644 packages/frontend/projects/upgrade/src/app/core/segments/list-details.data.service.spec.ts create mode 100644 packages/frontend/projects/upgrade/src/app/core/segments/list-details.data.service.ts create mode 100644 packages/frontend/projects/upgrade/src/app/core/segments/list-values.utils.spec.ts create mode 100644 packages/frontend/projects/upgrade/src/app/core/segments/list-values.utils.ts create mode 100644 packages/frontend/projects/upgrade/src/app/features/dashboard/segments/modals/edit-list-value-modal/edit-list-value-modal.component.html create mode 100644 packages/frontend/projects/upgrade/src/app/features/dashboard/segments/modals/edit-list-value-modal/edit-list-value-modal.component.ts create mode 100644 packages/frontend/projects/upgrade/src/app/features/dashboard/segments/modals/upsert-list-values-modal/upsert-list-values-modal.component.html create mode 100644 packages/frontend/projects/upgrade/src/app/features/dashboard/segments/modals/upsert-list-values-modal/upsert-list-values-modal.component.scss create mode 100644 packages/frontend/projects/upgrade/src/app/features/dashboard/segments/modals/upsert-list-values-modal/upsert-list-values-modal.component.ts create mode 100644 packages/frontend/projects/upgrade/src/app/features/dashboard/segments/pages/list-details-page/list-details-page.component.html create mode 100644 packages/frontend/projects/upgrade/src/app/features/dashboard/segments/pages/list-details-page/list-details-page.component.scss create mode 100644 packages/frontend/projects/upgrade/src/app/features/dashboard/segments/pages/list-details-page/list-details-page.component.ts diff --git a/packages/backend/src/api/repositories/ExperimentRepository.ts b/packages/backend/src/api/repositories/ExperimentRepository.ts index 59ed97969e..04612ef192 100644 --- a/packages/backend/src/api/repositories/ExperimentRepository.ts +++ b/packages/backend/src/api/repositories/ExperimentRepository.ts @@ -24,13 +24,15 @@ export class ExperimentRepository extends Repository { .addOrderBy('queries.order', 'ASC', 'NULLS LAST') .addOrderBy('queries.createdAt', 'ASC'); - const experimentSegment = this.buildSegmentQuery(); + const experimentInclusionSegmentQuery = this.buildInclusionSegmentQuery(); + const experimentExclusionSegmentQuery = this.buildExclusionSegmentQuery(); const [ experimentConditionLevelPayloadData, experimentFactorPartitionLevelPayloadData, experimentMetricData, - experimentSegmentData, + experimentInclusionSegmentData, + experimentExclusionSegmentData, ] = await Promise.all([ experimentConditionLevelPayloadQuery.getMany().catch((errorMsg: any) => { const errorMsgString = repositoryError( @@ -59,10 +61,19 @@ export class ExperimentRepository extends Repository { ); throw errorMsgString; }), - experimentSegment.getMany().catch((errorMsg: any) => { + experimentInclusionSegmentQuery.getMany().catch((errorMsg: any) => { const errorMsgString = repositoryError( 'ExperimentRepository', - 'findAllExperiments-experimentSegmentData', + 'findAllExperiments-experimentInclusionSegmentData', + {}, + errorMsg + ); + throw errorMsgString; + }), + experimentExclusionSegmentQuery.getMany().catch((errorMsg: any) => { + const errorMsgString = repositoryError( + 'ExperimentRepository', + 'findAllExperiments-experimentExclusionSegmentData', {}, errorMsg ); @@ -70,6 +81,8 @@ export class ExperimentRepository extends Repository { }), ]); + const experimentSegmentData = this.mergeSegmentData(experimentInclusionSegmentData, experimentExclusionSegmentData); + const experimentData = experimentConditionLevelPayloadData.map((data) => { const data2 = experimentFactorPartitionLevelPayloadData.find((i) => i.id === data.id); const data3 = experimentMetricData.find((i) => i.id === data.id); @@ -118,42 +131,63 @@ export class ExperimentRepository extends Repository { }) ); - const experimentSegmentQuery = this.buildSegmentQuery().where( + const experimentInclusionSegmentQuery = this.buildInclusionSegmentQuery().where( new Brackets((qb) => { qb.where(whereExperimentsClause, whereClauseParams); }) ); - const [experimentConditionLevelPayloadData, experimentFactorDecisionPointLevelPayloadData, experimentSegmentData] = - await Promise.all([ - experimentConditionLevelPayloadQuery.getMany().catch((errorMsg: any) => { - const errorMsgString = repositoryError( - 'ExperimentRepository', - 'getValidExperiments-experimentConditionLevelPayloadQuery', - {}, - errorMsg - ); - throw errorMsgString; - }), - experimentFactorDecisionPointLevelPayloadQuery.getMany().catch((errorMsg: any) => { - const errorMsgString = repositoryError( - 'ExperimentRepository', - 'getValidExperiments-experimentFactorDecisionPointLevelPayloadQuery', - {}, - errorMsg - ); - throw errorMsgString; - }), - experimentSegmentQuery.getMany().catch((errorMsg: any) => { - const errorMsgString = repositoryError( - 'ExperimentRepository', - 'getValidExperiments-experimentSegmentQuery', - {}, - errorMsg - ); - throw errorMsgString; - }), - ]); + const experimentExclusionSegmentQuery = this.buildExclusionSegmentQuery().where( + new Brackets((qb) => { + qb.where(whereExperimentsClause, whereClauseParams); + }) + ); + + const [ + experimentConditionLevelPayloadData, + experimentFactorDecisionPointLevelPayloadData, + experimentInclusionSegmentData, + experimentExclusionSegmentData, + ] = await Promise.all([ + experimentConditionLevelPayloadQuery.getMany().catch((errorMsg: any) => { + const errorMsgString = repositoryError( + 'ExperimentRepository', + 'getValidExperiments-experimentConditionLevelPayloadQuery', + {}, + errorMsg + ); + throw errorMsgString; + }), + experimentFactorDecisionPointLevelPayloadQuery.getMany().catch((errorMsg: any) => { + const errorMsgString = repositoryError( + 'ExperimentRepository', + 'getValidExperiments-experimentFactorDecisionPointLevelPayloadQuery', + {}, + errorMsg + ); + throw errorMsgString; + }), + experimentInclusionSegmentQuery.getMany().catch((errorMsg: any) => { + const errorMsgString = repositoryError( + 'ExperimentRepository', + 'getValidExperiments-experimentInclusionSegmentQuery', + {}, + errorMsg + ); + throw errorMsgString; + }), + experimentExclusionSegmentQuery.getMany().catch((errorMsg: any) => { + const errorMsgString = repositoryError( + 'ExperimentRepository', + 'getValidExperiments-experimentExclusionSegmentQuery', + {}, + errorMsg + ); + throw errorMsgString; + }), + ]); + + const experimentSegmentData = this.mergeSegmentData(experimentInclusionSegmentData, experimentExclusionSegmentData); const experimentData = experimentConditionLevelPayloadData.map((data) => { const data2 = experimentFactorDecisionPointLevelPayloadData.find((i) => i.id === data.id); @@ -204,7 +238,7 @@ export class ExperimentRepository extends Repository { }) ); - const segmentQuery = this.buildSegmentQuery() + const inclusionSegmentQuery = this.buildInclusionSegmentQuery() .leftJoin('experiment.partitions', 'partitions') .where( new Brackets((qb) => { @@ -212,35 +246,55 @@ export class ExperimentRepository extends Repository { }) ); - const [conditionLevelPayloadData, factorDecisionPointPayloadData, segmentData] = await Promise.all([ - conditionLevelPayloadQuery.getMany().catch((errorMsg: any) => { - const errorMsgString = repositoryError( - 'ExperimentRepository', - 'getValidExperimentsForContextAndDecisionPoint-conditionLevelPayloadData', - {}, - errorMsg - ); - throw errorMsgString; - }), - factorDecisionPointPayloadQuery.getMany().catch((errorMsg: any) => { - const errorMsgString = repositoryError( - 'ExperimentRepository', - 'getValidExperimentsForContextAndDecisionPoint-factorDecisionPointPayloadData', - {}, - errorMsg - ); - throw errorMsgString; - }), - segmentQuery.getMany().catch((errorMsg: any) => { - const errorMsgString = repositoryError( - 'ExperimentRepository', - 'getValidExperimentsForContextAndDecisionPoint-segmentData', - {}, - errorMsg - ); - throw errorMsgString; - }), - ]); + const exclusionSegmentQuery = this.buildExclusionSegmentQuery() + .leftJoin('experiment.partitions', 'partitions') + .where( + new Brackets((qb) => { + qb.where(decisionPointWhereClause, whereClauseParams); + }) + ); + + const [conditionLevelPayloadData, factorDecisionPointPayloadData, inclusionSegmentData, exclusionSegmentData] = + await Promise.all([ + conditionLevelPayloadQuery.getMany().catch((errorMsg: any) => { + const errorMsgString = repositoryError( + 'ExperimentRepository', + 'getValidExperimentsForContextAndDecisionPoint-conditionLevelPayloadData', + {}, + errorMsg + ); + throw errorMsgString; + }), + factorDecisionPointPayloadQuery.getMany().catch((errorMsg: any) => { + const errorMsgString = repositoryError( + 'ExperimentRepository', + 'getValidExperimentsForContextAndDecisionPoint-factorDecisionPointPayloadData', + {}, + errorMsg + ); + throw errorMsgString; + }), + inclusionSegmentQuery.getMany().catch((errorMsg: any) => { + const errorMsgString = repositoryError( + 'ExperimentRepository', + 'getValidExperimentsForContextAndDecisionPoint-inclusionSegmentData', + {}, + errorMsg + ); + throw errorMsgString; + }), + exclusionSegmentQuery.getMany().catch((errorMsg: any) => { + const errorMsgString = repositoryError( + 'ExperimentRepository', + 'getValidExperimentsForContextAndDecisionPoint-exclusionSegmentData', + {}, + errorMsg + ); + throw errorMsgString; + }), + ]); + + const segmentData = this.mergeSegmentData(inclusionSegmentData, exclusionSegmentData); const experimentData = factorDecisionPointPayloadData.map((data) => { const condData = conditionLevelPayloadData.find((i) => i.id === data.id); @@ -275,42 +329,63 @@ export class ExperimentRepository extends Repository { }) ); - const experimentSegmentQuery = this.buildSegmentQuery().where( + const experimentInclusionSegmentQuery = this.buildInclusionSegmentQuery().where( new Brackets((qb) => { qb.where(whereExperimentsClause, whereClauseParams); }) ); - const [experimentConditionLevelPayloadData, experimentFactorDecisionPointLevelPayloadData, experimentSegmentData] = - await Promise.all([ - experimentConditionLevelPayloadQuery.getMany().catch((errorMsg: any) => { - const errorMsgString = repositoryError( - 'ExperimentRepository', - 'getValidExperimentsWithPreview-experimentConditionLevelPayloadQuery', - {}, - errorMsg - ); - throw errorMsgString; - }), - experimentFactorDecisionPointLevelPayloadQuery.getMany().catch((errorMsg: any) => { - const errorMsgString = repositoryError( - 'ExperimentRepository', - 'getValidExperimentsWithPreview-experimentFactorDecisionPointLevelPayloadQuery', - {}, - errorMsg - ); - throw errorMsgString; - }), - experimentSegmentQuery.getMany().catch((errorMsg: any) => { - const errorMsgString = repositoryError( - 'ExperimentRepository', - 'getValidExperimentsWithPreview-experimentSegmentQuery', - {}, - errorMsg - ); - throw errorMsgString; - }), - ]); + const experimentExclusionSegmentQuery = this.buildExclusionSegmentQuery().where( + new Brackets((qb) => { + qb.where(whereExperimentsClause, whereClauseParams); + }) + ); + + const [ + experimentConditionLevelPayloadData, + experimentFactorDecisionPointLevelPayloadData, + experimentInclusionSegmentData, + experimentExclusionSegmentData, + ] = await Promise.all([ + experimentConditionLevelPayloadQuery.getMany().catch((errorMsg: any) => { + const errorMsgString = repositoryError( + 'ExperimentRepository', + 'getValidExperimentsWithPreview-experimentConditionLevelPayloadQuery', + {}, + errorMsg + ); + throw errorMsgString; + }), + experimentFactorDecisionPointLevelPayloadQuery.getMany().catch((errorMsg: any) => { + const errorMsgString = repositoryError( + 'ExperimentRepository', + 'getValidExperimentsWithPreview-experimentFactorDecisionPointLevelPayloadQuery', + {}, + errorMsg + ); + throw errorMsgString; + }), + experimentInclusionSegmentQuery.getMany().catch((errorMsg: any) => { + const errorMsgString = repositoryError( + 'ExperimentRepository', + 'getValidExperimentsWithPreview-experimentInclusionSegmentQuery', + {}, + errorMsg + ); + throw errorMsgString; + }), + experimentExclusionSegmentQuery.getMany().catch((errorMsg: any) => { + const errorMsgString = repositoryError( + 'ExperimentRepository', + 'getValidExperimentsWithPreview-experimentExclusionSegmentQuery', + {}, + errorMsg + ); + throw errorMsgString; + }), + ]); + + const experimentSegmentData = this.mergeSegmentData(experimentInclusionSegmentData, experimentExclusionSegmentData); const experimentData = experimentConditionLevelPayloadData.map((data) => { const data2 = experimentFactorDecisionPointLevelPayloadData.find((i) => i.id === data.id); @@ -468,14 +543,19 @@ export class ExperimentRepository extends Repository { .leftJoinAndSelect('factors.levels', 'levels'); } - private buildSegmentQuery() { + private buildInclusionSegmentQuery() { return this.createQueryBuilder('experiment') .select('experiment.id') .leftJoinAndSelect('experiment.experimentSegmentInclusion', 'experimentSegmentInclusion') .leftJoinAndSelect('experimentSegmentInclusion.segment', 'segmentInclusion') .leftJoinAndSelect('segmentInclusion.individualForSegment', 'individualForSegment') .leftJoinAndSelect('segmentInclusion.groupForSegment', 'groupForSegment') - .leftJoinAndSelect('segmentInclusion.subSegments', 'subSegment') + .leftJoinAndSelect('segmentInclusion.subSegments', 'subSegment'); + } + + private buildExclusionSegmentQuery() { + return this.createQueryBuilder('experiment') + .select('experiment.id') .leftJoinAndSelect('experiment.experimentSegmentExclusion', 'experimentSegmentExclusion') .leftJoinAndSelect('experimentSegmentExclusion.segment', 'segmentExclusion') .leftJoinAndSelect('segmentExclusion.individualForSegment', 'individualForSegmentExclusion') @@ -483,6 +563,28 @@ export class ExperimentRepository extends Repository { .leftJoinAndSelect('segmentExclusion.subSegments', 'subSegmentExclusion'); } + private mergeSegmentData(inclusionData: Experiment[], exclusionData: Experiment[]): Experiment[] { + const inclusionById = new Map(inclusionData.map((experiment) => [experiment.id, experiment])); + const exclusionById = new Map(exclusionData.map((experiment) => [experiment.id, experiment])); + const experimentIds = new Set([...inclusionById.keys(), ...exclusionById.keys()]); + + return [...experimentIds].map((experimentId) => { + const inclusion = inclusionById.get(experimentId); + const exclusion = exclusionById.get(experimentId); + + return { + ...inclusion, + ...exclusion, + ...(inclusion?.experimentSegmentInclusion !== undefined + ? { experimentSegmentInclusion: inclusion.experimentSegmentInclusion } + : {}), + ...(exclusion?.experimentSegmentExclusion !== undefined + ? { experimentSegmentExclusion: exclusion.experimentSegmentExclusion } + : {}), + } as Experiment; + }); + } + public async findOneExperiment(id: string): Promise { const conditionLevelPayloadQuery = this.buildConditionLevelPayloadQuery() .addOrderBy('conditions.order', 'ASC') @@ -502,51 +604,67 @@ export class ExperimentRepository extends Repository { .addOrderBy('queries.createdAt', 'ASC') .where({ id }); - const segmentQuery = this.buildSegmentQuery().where({ id }); + const inclusionSegmentQuery = this.buildInclusionSegmentQuery().where({ id }); + const exclusionSegmentQuery = this.buildExclusionSegmentQuery().where({ id }); - const [conditionLevelPayloadData, factorDecisionPointPayloadData, metricData, segmentData] = await Promise.all([ - conditionLevelPayloadQuery.getOne().catch((errorMsg: any) => { - const errorMsgString = repositoryError( - 'ExperimentRepository', - 'findOneExperiment-conditionLevelPayloadData', - { id }, - errorMsg - ); - throw errorMsgString; - }), - factorDecisionPointPayloadQuery.getOne().catch((errorMsg: any) => { - const errorMsgString = repositoryError( - 'ExperimentRepository', - 'findOneExperiment-factorDecisionPointPayloadData', - { id }, - errorMsg - ); - throw errorMsgString; - }), - metricQuery.getOne().catch((errorMsg: any) => { - const errorMsgString = repositoryError( - 'ExperimentRepository', - 'findOneExperiment-metricData', - { id }, - errorMsg - ); - throw errorMsgString; - }), - segmentQuery.getOne().catch((errorMsg: any) => { - const errorMsgString = repositoryError( - 'ExperimentRepository', - 'findOneExperiment-segmentData', - { id }, - errorMsg - ); - throw errorMsgString; - }), - ]); + const [conditionLevelPayloadData, factorDecisionPointPayloadData, metricData, inclusionData, exclusionData] = + await Promise.all([ + conditionLevelPayloadQuery.getOne().catch((errorMsg: any) => { + const errorMsgString = repositoryError( + 'ExperimentRepository', + 'findOneExperiment-conditionLevelPayloadData', + { id }, + errorMsg + ); + throw errorMsgString; + }), + factorDecisionPointPayloadQuery.getOne().catch((errorMsg: any) => { + const errorMsgString = repositoryError( + 'ExperimentRepository', + 'findOneExperiment-factorDecisionPointPayloadData', + { id }, + errorMsg + ); + throw errorMsgString; + }), + metricQuery.getOne().catch((errorMsg: any) => { + const errorMsgString = repositoryError( + 'ExperimentRepository', + 'findOneExperiment-metricData', + { id }, + errorMsg + ); + throw errorMsgString; + }), + inclusionSegmentQuery.getOne().catch((errorMsg: any) => { + const errorMsgString = repositoryError( + 'ExperimentRepository', + 'findOneExperiment-inclusionSegmentData', + { id }, + errorMsg + ); + throw errorMsgString; + }), + exclusionSegmentQuery.getOne().catch((errorMsg: any) => { + const errorMsgString = repositoryError( + 'ExperimentRepository', + 'findOneExperiment-exclusionSegmentData', + { id }, + errorMsg + ); + throw errorMsgString; + }), + ]); if (!conditionLevelPayloadData) { return undefined; } + const [segmentData] = this.mergeSegmentData( + inclusionData ? [inclusionData] : [], + exclusionData ? [exclusionData] : [] + ); + return { ...conditionLevelPayloadData, ...factorDecisionPointPayloadData, ...metricData, ...segmentData }; } diff --git a/packages/backend/test/unit/repositories/ExperimentRepository.test.ts b/packages/backend/test/unit/repositories/ExperimentRepository.test.ts index da64077b43..ce6d2978bb 100644 --- a/packages/backend/test/unit/repositories/ExperimentRepository.test.ts +++ b/packages/backend/test/unit/repositories/ExperimentRepository.test.ts @@ -155,11 +155,11 @@ describe('ExperimentRepository Testing', () => { const res = await repo.findAllExperiments(); - expect(repo.createQueryBuilder).toHaveBeenCalledTimes(4); + expect(repo.createQueryBuilder).toHaveBeenCalledTimes(5); expect(mock.leftJoinAndSelect).toHaveBeenCalledTimes(23); - expect(mock.select).toHaveBeenCalledTimes(1); - expect(mock.getMany).toHaveBeenCalledTimes(4); + expect(mock.select).toHaveBeenCalledTimes(2); + expect(mock.getMany).toHaveBeenCalledTimes(5); // queries are ordered by `order` ASC (NULLS LAST) then `createdAt` ASC as a stable fallback expect(mock.addOrderBy).toHaveBeenCalledTimes(2); @@ -176,11 +176,11 @@ describe('ExperimentRepository Testing', () => { await repo.findAllExperiments(); }).rejects.toThrow(err); - expect(repo.createQueryBuilder).toHaveBeenCalledTimes(4); + expect(repo.createQueryBuilder).toHaveBeenCalledTimes(5); expect(mock.leftJoinAndSelect).toHaveBeenCalledTimes(23); - expect(mock.select).toHaveBeenCalledTimes(1); - expect(mock.getMany).toHaveBeenCalledTimes(4); + expect(mock.select).toHaveBeenCalledTimes(2); + expect(mock.getMany).toHaveBeenCalledTimes(5); }); it('should find all experiments by name', async () => { @@ -213,12 +213,12 @@ describe('ExperimentRepository Testing', () => { const res = await repo.getValidExperiments('context'); - expect(repo.createQueryBuilder).toHaveBeenCalledTimes(3); + expect(repo.createQueryBuilder).toHaveBeenCalledTimes(4); expect(mock.leftJoinAndSelect).toHaveBeenCalledTimes(20); - expect(mock.where).toHaveBeenCalledTimes(3); - expect(mock.select).toHaveBeenCalledTimes(1); - expect(mock.getMany).toHaveBeenCalledTimes(3); + expect(mock.where).toHaveBeenCalledTimes(4); + expect(mock.select).toHaveBeenCalledTimes(2); + expect(mock.getMany).toHaveBeenCalledTimes(4); expect(res).toEqual(result); }); @@ -230,12 +230,12 @@ describe('ExperimentRepository Testing', () => { await repo.getValidExperiments('context'); }).rejects.toThrow(err); - expect(repo.createQueryBuilder).toHaveBeenCalledTimes(3); + expect(repo.createQueryBuilder).toHaveBeenCalledTimes(4); expect(mock.leftJoinAndSelect).toHaveBeenCalledTimes(20); - expect(mock.where).toHaveBeenCalledTimes(3); - expect(mock.select).toHaveBeenCalledTimes(1); - expect(mock.getMany).toHaveBeenCalledTimes(3); + expect(mock.where).toHaveBeenCalledTimes(4); + expect(mock.select).toHaveBeenCalledTimes(2); + expect(mock.getMany).toHaveBeenCalledTimes(4); }); it('should get valid experiments with preview', async () => { @@ -244,12 +244,12 @@ describe('ExperimentRepository Testing', () => { const res = await repo.getValidExperimentsWithPreview('context'); - expect(repo.createQueryBuilder).toHaveBeenCalledTimes(3); + expect(repo.createQueryBuilder).toHaveBeenCalledTimes(4); expect(mock.leftJoinAndSelect).toHaveBeenCalledTimes(20); - expect(mock.where).toHaveBeenCalledTimes(3); - expect(mock.select).toHaveBeenCalledTimes(1); - expect(mock.getMany).toHaveBeenCalledTimes(3); + expect(mock.where).toHaveBeenCalledTimes(4); + expect(mock.select).toHaveBeenCalledTimes(2); + expect(mock.getMany).toHaveBeenCalledTimes(4); expect(res).toEqual(result); }); @@ -261,12 +261,12 @@ describe('ExperimentRepository Testing', () => { await repo.getValidExperimentsWithPreview('context'); }).rejects.toThrow(err); - expect(repo.createQueryBuilder).toHaveBeenCalledTimes(3); + expect(repo.createQueryBuilder).toHaveBeenCalledTimes(4); expect(mock.leftJoinAndSelect).toHaveBeenCalledTimes(20); - expect(mock.where).toHaveBeenCalledTimes(3); - expect(mock.select).toHaveBeenCalledTimes(1); - expect(mock.getMany).toHaveBeenCalledTimes(3); + expect(mock.where).toHaveBeenCalledTimes(4); + expect(mock.select).toHaveBeenCalledTimes(2); + expect(mock.getMany).toHaveBeenCalledTimes(4); }); it('should update experiment state', async () => { @@ -369,16 +369,16 @@ describe('ExperimentRepository Testing', () => { it('should find one experiment ordered by queries.order then createdAt', async () => { const res = await repo.findOneExperiment(experiment.id); - // 4 parallel queries: conditionLevelPayload, factorDecisionPointPayload, metric, segment - expect(repo.createQueryBuilder).toHaveBeenCalledTimes(4); + // 5 parallel queries: conditionLevelPayload, factorDecisionPointPayload, metric, inclusion, exclusion + expect(repo.createQueryBuilder).toHaveBeenCalledTimes(5); // conditions(1) + partitions+factors+levels(3) + queries.order+createdAt(2) = 6 addOrderBy calls expect(mock.addOrderBy).toHaveBeenCalledTimes(6); expect(mock.addOrderBy).toHaveBeenCalledWith('queries.order', 'ASC', 'NULLS LAST'); expect(mock.addOrderBy).toHaveBeenCalledWith('queries.createdAt', 'ASC'); - expect(mock.where).toHaveBeenCalledTimes(4); - expect(mock.getOne).toHaveBeenCalledTimes(4); + expect(mock.where).toHaveBeenCalledTimes(5); + expect(mock.getOne).toHaveBeenCalledTimes(5); expect(res).toEqual(experiment); }); @@ -412,29 +412,33 @@ describe('ExperimentRepository Testing', () => { }); describe('getValidExperimentsForContextAndDecisionPoint', () => { - it('should build three queries and add a leftJoin on partitions (decision points) for the condition and segment queries', async () => { + it('should build four queries and add a leftJoin on partitions for the condition and segment queries', async () => { const result = [experiment]; mock.getMany.mockResolvedValue(result); const res = await repo.getValidExperimentsForContextAndDecisionPoint('context', 'site1', 'target1'); - expect(repo.createQueryBuilder).toHaveBeenCalledTimes(3); - // 4 (conditionLevel) + 6 (factorDecisionPoint) + 10 (segment) = 20 + expect(repo.createQueryBuilder).toHaveBeenCalledTimes(4); + // 4 (conditionLevel) + 6 (factorDecisionPoint) + 5 (inclusion) + 5 (exclusion) = 20 expect(mock.leftJoinAndSelect).toHaveBeenCalledTimes(20); - // conditionLevelPayloadQuery and segmentQuery each add a non-selecting leftJoin for partition filtering - expect(mock.leftJoin).toHaveBeenCalledTimes(2); + // conditionLevelPayloadQuery and both segment queries add a non-selecting leftJoin for partition filtering + expect(mock.leftJoin).toHaveBeenCalledTimes(3); expect(mock.leftJoin).toHaveBeenCalledWith('experiment.partitions', 'partitions'); - // buildSegmentQuery calls .select('experiment.id') - expect(mock.select).toHaveBeenCalledTimes(1); - expect(mock.where).toHaveBeenCalledTimes(3); - expect(mock.getMany).toHaveBeenCalledTimes(3); + // Both segment queries call .select('experiment.id') + expect(mock.select).toHaveBeenCalledTimes(2); + expect(mock.where).toHaveBeenCalledTimes(4); + expect(mock.getMany).toHaveBeenCalledTimes(4); expect(res).toEqual(result); }); it('should return empty array when no experiments match the site/target', async () => { // conditionLevel and segment find experiments, but factorDecisionPoint finds none at this site/target - mock.getMany.mockResolvedValueOnce([experiment]).mockResolvedValueOnce([]).mockResolvedValueOnce([experiment]); + mock.getMany + .mockResolvedValueOnce([experiment]) + .mockResolvedValueOnce([]) + .mockResolvedValueOnce([experiment]) + .mockResolvedValueOnce([experiment]); const res = await repo.getValidExperimentsForContextAndDecisionPoint('context', 'site1', 'target1'); @@ -451,6 +455,7 @@ describe('ExperimentRepository Testing', () => { mock.getMany .mockResolvedValueOnce([expA, expB]) .mockResolvedValueOnce([expA]) + .mockResolvedValueOnce([expA, expB]) .mockResolvedValueOnce([expA, expB]); const res = await repo.getValidExperimentsForContextAndDecisionPoint('context', 'site1', 'target1'); @@ -462,12 +467,14 @@ describe('ExperimentRepository Testing', () => { it('should merge condition, partition, and segment data onto each result experiment', async () => { const condData = { id: 'exp-a', conditions: ['cond1'] } as any; const factorData = { id: 'exp-a', partitions: ['part1'] } as any; - const segData = { id: 'exp-a', experimentSegmentInclusion: ['seg1'] } as any; + const inclusionData = { id: 'exp-a', experimentSegmentInclusion: ['seg1'] } as any; + const exclusionData = { id: 'exp-a', experimentSegmentExclusion: ['seg2'] } as any; mock.getMany .mockResolvedValueOnce([condData]) .mockResolvedValueOnce([factorData]) - .mockResolvedValueOnce([segData]); + .mockResolvedValueOnce([inclusionData]) + .mockResolvedValueOnce([exclusionData]); const [result] = await repo.getValidExperimentsForContextAndDecisionPoint('context', 'site1', 'target1'); @@ -476,6 +483,7 @@ describe('ExperimentRepository Testing', () => { conditions: ['cond1'], partitions: ['part1'], experimentSegmentInclusion: ['seg1'], + experimentSegmentExclusion: ['seg2'], }); }); @@ -483,7 +491,11 @@ describe('ExperimentRepository Testing', () => { const condData = { id: 'exp-a', conditions: ['cond1'] } as any; const factorData = { id: 'exp-a', partitions: ['part1'] } as any; - mock.getMany.mockResolvedValueOnce([condData]).mockResolvedValueOnce([factorData]).mockResolvedValueOnce([]); + mock.getMany + .mockResolvedValueOnce([condData]) + .mockResolvedValueOnce([factorData]) + .mockResolvedValueOnce([]) + .mockResolvedValueOnce([]); const [result] = await repo.getValidExperimentsForContextAndDecisionPoint('context', 'site1', 'target1'); @@ -492,13 +504,23 @@ describe('ExperimentRepository Testing', () => { it('should return factorDecisionPoint data even when conditionLevel query returns no match', async () => { const factorData = { id: 'exp-a', partitions: ['part1'] } as any; - const segData = { id: 'exp-a', experimentSegmentInclusion: ['seg1'] } as any; + const inclusionData = { id: 'exp-a', experimentSegmentInclusion: ['seg1'] } as any; + const exclusionData = { id: 'exp-a', experimentSegmentExclusion: ['seg2'] } as any; - mock.getMany.mockResolvedValueOnce([]).mockResolvedValueOnce([factorData]).mockResolvedValueOnce([segData]); + mock.getMany + .mockResolvedValueOnce([]) + .mockResolvedValueOnce([factorData]) + .mockResolvedValueOnce([inclusionData]) + .mockResolvedValueOnce([exclusionData]); const [result] = await repo.getValidExperimentsForContextAndDecisionPoint('context', 'site1', 'target1'); - expect(result).toMatchObject({ id: 'exp-a', partitions: ['part1'], experimentSegmentInclusion: ['seg1'] }); + expect(result).toMatchObject({ + id: 'exp-a', + partitions: ['part1'], + experimentSegmentInclusion: ['seg1'], + experimentSegmentExclusion: ['seg2'], + }); }); it('should throw an error when a sub-query fails', async () => { @@ -506,7 +528,7 @@ describe('ExperimentRepository Testing', () => { await expect(repo.getValidExperimentsForContextAndDecisionPoint('context', 'site1', 'target1')).rejects.toThrow(); - expect(repo.createQueryBuilder).toHaveBeenCalledTimes(3); + expect(repo.createQueryBuilder).toHaveBeenCalledTimes(4); }); }); diff --git a/packages/frontend/projects/upgrade/src/app/core/experiments/store/experiments.effects.ts b/packages/frontend/projects/upgrade/src/app/core/experiments/store/experiments.effects.ts index d2c8f23454..ae3a3a20a6 100644 --- a/packages/frontend/projects/upgrade/src/app/core/experiments/store/experiments.effects.ts +++ b/packages/frontend/projects/upgrade/src/app/core/experiments/store/experiments.effects.ts @@ -34,6 +34,8 @@ import JSZip from 'jszip'; import { TranslateService } from '@ngx-translate/core'; import { CommonModalEventsService } from '../../../shared/services/common-modal-event.service'; import { CommonExportHelpersService } from '../../../shared/services/common-export-helpers.service'; +import { LIST_FILTER_MODE } from 'upgrade_types'; +import { LIST_OPTION_TYPE } from '../../segments/store/segments.model'; @Injectable() export class ExperimentEffects { constructor( @@ -560,6 +562,16 @@ export class ExperimentEffects { map((listResponse) => { this.notificationService.showSuccess(this.translate.instant('experiments.inclusions.add-success.text')); this.commonModalEvents.forceCloseModal(); + if (action.list.list.listType?.toLowerCase() !== LIST_OPTION_TYPE.SEGMENT.toLowerCase()) { + this.router.navigate([ + '/home', + 'detail', + action.list.experimentId, + 'list', + LIST_FILTER_MODE.INCLUSION, + listResponse.segment.id, + ]); + } return experimentAction.actionAddExperimentInclusionListSuccess({ listResponse }); }), catchError((error) => { @@ -617,6 +629,16 @@ export class ExperimentEffects { map((listResponse) => { this.notificationService.showSuccess(this.translate.instant('experiments.exclusions.add-success.text')); this.commonModalEvents.forceCloseModal(); + if (action.list.list.listType?.toLowerCase() !== LIST_OPTION_TYPE.SEGMENT.toLowerCase()) { + this.router.navigate([ + '/home', + 'detail', + action.list.experimentId, + 'list', + LIST_FILTER_MODE.EXCLUSION, + listResponse.segment.id, + ]); + } return experimentAction.actionAddExperimentExclusionListSuccess({ listResponse }); }), catchError((error) => { diff --git a/packages/frontend/projects/upgrade/src/app/core/feature-flags/store/feature-flags.effects.ts b/packages/frontend/projects/upgrade/src/app/core/feature-flags/store/feature-flags.effects.ts index 41d2304efe..7835760fe0 100644 --- a/packages/frontend/projects/upgrade/src/app/core/feature-flags/store/feature-flags.effects.ts +++ b/packages/frontend/projects/upgrade/src/app/core/feature-flags/store/feature-flags.effects.ts @@ -13,7 +13,8 @@ import { selectSearchString, selectFeatureFlagPaginationParams } from './feature 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'; +import { LIST_FILTER_MODE, SERVER_ERROR } from 'upgrade_types'; +import { LIST_OPTION_TYPE } from '../../segments/store/segments.model'; import { CommonModalEventsService } from '../../../shared/services/common-modal-event.service'; @Injectable() @@ -189,6 +190,16 @@ export class FeatureFlagsEffects { map((listResponse) => { this.notificationService.showSuccess(this.translate.instant('feature-flags.inclusions.add-success.text')); this.commonModalEvents.forceCloseModal(); + if (action.list.listType?.toLowerCase() !== LIST_OPTION_TYPE.SEGMENT.toLowerCase()) { + this.router.navigate([ + '/featureflags', + 'detail', + action.list.id, + 'list', + LIST_FILTER_MODE.INCLUSION, + listResponse.segment.id, + ]); + } return FeatureFlagsActions.actionAddFeatureFlagInclusionListSuccess({ listResponse }); }), catchError((error) => { @@ -270,6 +281,16 @@ export class FeatureFlagsEffects { map((listResponse) => { this.notificationService.showSuccess(this.translate.instant('feature-flags.exclusions.add-success.text')); this.commonModalEvents.forceCloseModal(); + if (action.list.listType?.toLowerCase() !== LIST_OPTION_TYPE.SEGMENT.toLowerCase()) { + this.router.navigate([ + '/featureflags', + 'detail', + action.list.id, + 'list', + LIST_FILTER_MODE.EXCLUSION, + listResponse.segment.id, + ]); + } return FeatureFlagsActions.actionAddFeatureFlagExclusionListSuccess({ listResponse }); }), catchError((error) => { diff --git a/packages/frontend/projects/upgrade/src/app/core/segments/list-details.data.service.spec.ts b/packages/frontend/projects/upgrade/src/app/core/segments/list-details.data.service.spec.ts new file mode 100644 index 0000000000..5d922b74dd --- /dev/null +++ b/packages/frontend/projects/upgrade/src/app/core/segments/list-details.data.service.spec.ts @@ -0,0 +1,141 @@ +import { of } from 'rxjs'; +import { LIST_FILTER_MODE, SEGMENT_TYPE } from 'upgrade_types'; +import { ExperimentDataService } from '../experiments/experiments.data.service'; +import { FeatureFlagsDataService } from '../feature-flags/feature-flags.data.service'; +import { ListDetailsDataService } from './list-details.data.service'; +import { SegmentsDataService } from './segments.data.service'; +import { EditPrivateSegmentListDetails, LIST_OWNER_TYPE, Segment } from './store/segments.model'; + +describe('ListDetailsDataService', () => { + let service: ListDetailsDataService; + let experimentDataService: { [key: string]: jest.Mock }; + let featureFlagsDataService: { [key: string]: jest.Mock }; + let segmentsDataService: { [key: string]: jest.Mock }; + + const segment = { + id: 'list-id', + name: 'Test list', + description: '', + context: 'test', + type: SEGMENT_TYPE.PRIVATE, + listType: 'Individual', + } as Segment; + + const segmentRequest: EditPrivateSegmentListDetails = { + id: segment.id, + name: segment.name, + description: segment.description, + context: segment.context, + type: SEGMENT_TYPE.PRIVATE, + userIds: ['one'], + groups: [], + subSegmentIds: [], + listType: 'Individual', + }; + + beforeEach(() => { + experimentDataService = { + getExperimentById: jest.fn(), + updateInclusionList: jest.fn(), + updateExclusionList: jest.fn(), + deleteInclusionList: jest.fn(), + deleteExclusionList: jest.fn(), + }; + featureFlagsDataService = { + fetchFeatureFlagById: jest.fn(), + updateInclusionList: jest.fn(), + updateExclusionList: jest.fn(), + deleteInclusionList: jest.fn(), + deleteExclusionList: jest.fn(), + }; + segmentsDataService = { + fetchSegmentWithMembersById: jest.fn(), + getSegmentById: jest.fn(), + updateSegmentList: jest.fn(), + deleteSegmentList: jest.fn(), + }; + + service = new ListDetailsDataService( + experimentDataService as unknown as ExperimentDataService, + featureFlagsDataService as unknown as FeatureFlagsDataService, + segmentsDataService as unknown as SegmentsDataService + ); + }); + + it('loads a feature flag owner and preserves the include-list enabled state', (done) => { + featureFlagsDataService.fetchFeatureFlagById.mockReturnValue( + of({ + id: 'flag-id', + name: 'Test flag', + featureFlagSegmentInclusion: [{ segment, enabled: true }], + featureFlagSegmentExclusion: [], + }) + ); + + service + .fetchOwner(LIST_OWNER_TYPE.FEATURE_FLAG, 'flag-id', LIST_FILTER_MODE.INCLUSION, segment.id) + .subscribe((owner) => { + expect(owner).toEqual({ + id: 'flag-id', + name: 'Test flag', + type: LIST_OWNER_TYPE.FEATURE_FLAG, + listEnabled: true, + }); + done(); + }); + }); + + it('uses the experiment inclusion endpoint with the existing full-list payload', (done) => { + experimentDataService.updateInclusionList.mockReturnValue(of({ segment })); + + service + .updateList( + LIST_OWNER_TYPE.EXPERIMENT, + LIST_FILTER_MODE.INCLUSION, + 'experiment-id', + false, + 'Individual', + segmentRequest + ) + .subscribe((result) => { + expect(experimentDataService.updateInclusionList).toHaveBeenCalledWith({ + experimentId: 'experiment-id', + list: { ...segmentRequest, listType: 'Individual' }, + }); + expect(result).toBe(segment); + done(); + }); + }); + + it('preserves feature flag list status when updating values', (done) => { + featureFlagsDataService.updateExclusionList.mockReturnValue(of({ segment })); + + service + .updateList( + LIST_OWNER_TYPE.FEATURE_FLAG, + LIST_FILTER_MODE.EXCLUSION, + 'flag-id', + true, + 'Individual', + segmentRequest + ) + .subscribe(() => { + expect(featureFlagsDataService.updateExclusionList).toHaveBeenCalledWith({ + id: 'flag-id', + enabled: true, + listType: 'Individual', + segment: segmentRequest, + }); + done(); + }); + }); + + it('deletes a nested segment list with its parent segment id', (done) => { + segmentsDataService.deleteSegmentList.mockReturnValue(of(undefined)); + + service.deleteList(LIST_OWNER_TYPE.SEGMENT, LIST_FILTER_MODE.EXCLUSION, 'parent-id', segment.id).subscribe(() => { + expect(segmentsDataService.deleteSegmentList).toHaveBeenCalledWith(segment.id, 'parent-id'); + done(); + }); + }); +}); diff --git a/packages/frontend/projects/upgrade/src/app/core/segments/list-details.data.service.ts b/packages/frontend/projects/upgrade/src/app/core/segments/list-details.data.service.ts new file mode 100644 index 0000000000..8715e090e3 --- /dev/null +++ b/packages/frontend/projects/upgrade/src/app/core/segments/list-details.data.service.ts @@ -0,0 +1,125 @@ +import { Injectable } from '@angular/core'; +import { Observable, map } from 'rxjs'; +import { LIST_FILTER_MODE } from 'upgrade_types'; +import { ExperimentDataService } from '../experiments/experiments.data.service'; +import { Experiment } from '../experiments/store/experiments.model'; +import { FeatureFlagsDataService } from '../feature-flags/feature-flags.data.service'; +import { FeatureFlag } from '../feature-flags/store/feature-flags.model'; +import { SegmentsDataService } from './segments.data.service'; +import { + EditPrivateSegmentListDetails, + EditPrivateSegmentListRequest, + ExperimentSegmentListRequest, + LIST_OWNER_TYPE, + ListDetailsOwner, + Segment, +} from './store/segments.model'; + +@Injectable({ providedIn: 'root' }) +export class ListDetailsDataService { + constructor( + private experimentDataService: ExperimentDataService, + private featureFlagsDataService: FeatureFlagsDataService, + private segmentsDataService: SegmentsDataService + ) {} + + fetchList(listId: string): Observable { + return this.segmentsDataService.fetchSegmentWithMembersById(listId); + } + + fetchOwner( + ownerType: LIST_OWNER_TYPE, + ownerId: string, + filterMode: LIST_FILTER_MODE, + listId: string + ): Observable { + switch (ownerType) { + case LIST_OWNER_TYPE.EXPERIMENT: + return this.experimentDataService.getExperimentById(ownerId).pipe( + map((experiment: Experiment) => ({ + id: experiment.id, + name: experiment.name, + type: ownerType, + })) + ); + case LIST_OWNER_TYPE.FEATURE_FLAG: + return this.featureFlagsDataService.fetchFeatureFlagById(ownerId).pipe( + map((featureFlag: FeatureFlag) => { + const lists = + filterMode === LIST_FILTER_MODE.INCLUSION + ? featureFlag.featureFlagSegmentInclusion + : featureFlag.featureFlagSegmentExclusion; + return { + id: featureFlag.id, + name: featureFlag.name, + type: ownerType, + listEnabled: lists?.find((list) => list.segment.id === listId)?.enabled, + }; + }) + ); + case LIST_OWNER_TYPE.SEGMENT: + return this.segmentsDataService.getSegmentById(ownerId).pipe( + map((response: { segment: Segment }) => ({ + id: response.segment.id, + name: response.segment.name, + type: ownerType, + segmentType: response.segment.type, + })) + ); + } + } + + updateList( + ownerType: LIST_OWNER_TYPE, + filterMode: LIST_FILTER_MODE, + ownerId: string, + enabled: boolean, + listType: string, + segment: EditPrivateSegmentListDetails + ): Observable { + if (ownerType === LIST_OWNER_TYPE.EXPERIMENT) { + const request: ExperimentSegmentListRequest = { + experimentId: ownerId, + list: { ...segment, listType }, + }; + const update$ = + filterMode === LIST_FILTER_MODE.INCLUSION + ? this.experimentDataService.updateInclusionList(request) + : this.experimentDataService.updateExclusionList(request); + return update$.pipe(map((response) => response.segment)); + } + + const request: EditPrivateSegmentListRequest = { + id: ownerId, + enabled, + listType, + segment, + }; + + if (ownerType === LIST_OWNER_TYPE.FEATURE_FLAG) { + const update$ = + filterMode === LIST_FILTER_MODE.INCLUSION + ? this.featureFlagsDataService.updateInclusionList(request) + : this.featureFlagsDataService.updateExclusionList(request); + return update$.pipe(map((response) => response.segment)); + } + + return this.segmentsDataService.updateSegmentList(request).pipe(map((response) => response.segment)); + } + + deleteList(ownerType: LIST_OWNER_TYPE, filterMode: LIST_FILTER_MODE, ownerId: string, listId: string) { + if (ownerType === LIST_OWNER_TYPE.EXPERIMENT) { + return filterMode === LIST_FILTER_MODE.INCLUSION + ? this.experimentDataService.deleteInclusionList(listId) + : this.experimentDataService.deleteExclusionList(listId); + } + + if (ownerType === LIST_OWNER_TYPE.FEATURE_FLAG) { + return filterMode === LIST_FILTER_MODE.INCLUSION + ? this.featureFlagsDataService.deleteInclusionList(listId) + : this.featureFlagsDataService.deleteExclusionList(listId); + } + + return this.segmentsDataService.deleteSegmentList(listId, ownerId); + } +} diff --git a/packages/frontend/projects/upgrade/src/app/core/segments/list-values.utils.spec.ts b/packages/frontend/projects/upgrade/src/app/core/segments/list-values.utils.spec.ts new file mode 100644 index 0000000000..193d25b70e --- /dev/null +++ b/packages/frontend/projects/upgrade/src/app/core/segments/list-values.utils.spec.ts @@ -0,0 +1,58 @@ +import { + MAX_LIST_VALUES, + exceedsListValueLimit, + mergeUniqueListValues, + parseSingleColumnCSV, + splitListValues, +} from './list-values.utils'; + +describe('list values utilities', () => { + describe('splitListValues', () => { + it('splits pasted values on commas, tabs, and new lines', () => { + expect(splitListValues('one, two\tthree\nfour\r\nfive')).toEqual(['one', 'two', 'three', 'four', 'five']); + }); + + it('trims values and drops empty entries', () => { + expect(splitListValues(' one, ,\n two ')).toEqual(['one', 'two']); + }); + }); + + describe('mergeUniqueListValues', () => { + it('keeps existing order and reports duplicate values', () => { + expect(mergeUniqueListValues(['one', 'two'], ['two', 'three', 'three'])).toEqual({ + values: ['one', 'two', 'three'], + addedValues: ['three'], + duplicateValues: ['two', 'three'], + }); + }); + + it('handles the 3,000-value WIP target', () => { + const values = Array.from({ length: MAX_LIST_VALUES }, (_, index) => `value-${index}`); + + expect(mergeUniqueListValues([], values).values).toHaveLength(MAX_LIST_VALUES); + }); + }); + + describe('exceedsListValueLimit', () => { + const existingValues = Array.from({ length: MAX_LIST_VALUES }, (_, index) => `value-${index}`); + + it('allows duplicate input when the list is already at the limit', () => { + expect(exceedsListValueLimit(existingValues, ['value-0'])).toBe(false); + }); + + it('blocks a new value when the list is already at the limit', () => { + expect(exceedsListValueLimit(existingValues, ['new-value'])).toBe(true); + }); + }); + + describe('parseSingleColumnCSV', () => { + it('parses a single-column CSV without a header', () => { + expect(parseSingleColumnCSV('one\ntwo\r\nthree')).toEqual(['one', 'two', 'three']); + }); + + it('rejects empty and multi-column CSV files', () => { + expect(() => parseSingleColumnCSV('')).toThrow('CSV file is empty'); + expect(() => parseSingleColumnCSV('one,two')).toThrow('CSV should contain only one column'); + }); + }); +}); diff --git a/packages/frontend/projects/upgrade/src/app/core/segments/list-values.utils.ts b/packages/frontend/projects/upgrade/src/app/core/segments/list-values.utils.ts new file mode 100644 index 0000000000..7c95951448 --- /dev/null +++ b/packages/frontend/projects/upgrade/src/app/core/segments/list-values.utils.ts @@ -0,0 +1,64 @@ +export interface MergeListValuesResult { + values: string[]; + addedValues: string[]; + duplicateValues: string[]; +} + +export const MAX_LIST_VALUES = 3000; + +const VALUE_SEPARATORS = /[,\t\r\n]+/; + +export function splitListValues(rawValue: string): string[] { + return rawValue + .split(VALUE_SEPARATORS) + .map((value) => value.trim()) + .filter(Boolean); +} + +export function mergeUniqueListValues(existingValues: string[], incomingValues: string[]): MergeListValuesResult { + const seenValues = new Set(existingValues); + const addedValues: string[] = []; + const duplicateValues: string[] = []; + + incomingValues.forEach((value) => { + const normalizedValue = value.trim(); + if (!normalizedValue) { + return; + } + + if (seenValues.has(normalizedValue)) { + duplicateValues.push(normalizedValue); + return; + } + + seenValues.add(normalizedValue); + addedValues.push(normalizedValue); + }); + + return { + values: [...existingValues, ...addedValues], + addedValues, + duplicateValues, + }; +} + +export function exceedsListValueLimit(existingValues: string[], incomingValues: string[]): boolean { + return mergeUniqueListValues(existingValues, incomingValues).values.length > MAX_LIST_VALUES; +} + +export function parseSingleColumnCSV(content: string): string[] { + const values = content + .split(/\r?\n/) + .map((value) => value.trim()) + .filter(Boolean); + + if (!values.length) { + throw new Error('CSV file is empty'); + } + + if (values.some((value) => value.includes(','))) { + throw new Error('CSV should contain only one column'); + } + + return values; +} diff --git a/packages/frontend/projects/upgrade/src/app/core/segments/store/segments.effects.ts b/packages/frontend/projects/upgrade/src/app/core/segments/store/segments.effects.ts index 35aff13c1d..a04237f87d 100644 --- a/packages/frontend/projects/upgrade/src/app/core/segments/store/segments.effects.ts +++ b/packages/frontend/projects/upgrade/src/app/core/segments/store/segments.effects.ts @@ -7,7 +7,13 @@ import { AppState, NotificationService } from '../../core.module'; import { TranslateService } from '@ngx-translate/core'; import { SegmentsDataService } from '../segments.data.service'; import * as SegmentsActions from './segments.actions'; -import { NUMBER_OF_SEGMENTS, Segment, SegmentsPaginationParams, UpsertSegmentType } from './segments.model'; +import { + LIST_OPTION_TYPE, + NUMBER_OF_SEGMENTS, + Segment, + SegmentsPaginationParams, + UpsertSegmentType, +} from './segments.model'; import { selectAllSegments, selectGlobalSegments, @@ -16,7 +22,7 @@ import { } from './segments.selectors'; import JSZip from 'jszip'; import { of } from 'rxjs'; -import { SEGMENT_STATUS, SERVER_ERROR } from 'upgrade_types'; +import { LIST_FILTER_MODE, SEGMENT_STATUS, SERVER_ERROR } from 'upgrade_types'; import { SegmentsService } from '../segments.service'; import { CommonModalEventsService } from '../../../shared/services/common-modal-event.service'; @@ -301,6 +307,16 @@ export class SegmentsEffects { map((listResponse) => { this.notificationService.showSuccess(this.translate.instant('segments.lists.add-success.text')); this.commonModalEventService.forceCloseModal(); + if (action.list.listType?.toLowerCase() !== LIST_OPTION_TYPE.SEGMENT.toLowerCase()) { + this.router.navigate([ + '/segments', + 'detail', + action.list.id, + 'list', + LIST_FILTER_MODE.EXCLUSION, + listResponse.segment.id, + ]); + } return SegmentsActions.actionAddSegmentListSuccess({ listResponse }); }), catchError((error) => { diff --git a/packages/frontend/projects/upgrade/src/app/core/segments/store/segments.model.ts b/packages/frontend/projects/upgrade/src/app/core/segments/store/segments.model.ts index bb36019ebb..77fbc9aa46 100644 --- a/packages/frontend/projects/upgrade/src/app/core/segments/store/segments.model.ts +++ b/packages/frontend/projects/upgrade/src/app/core/segments/store/segments.model.ts @@ -310,6 +310,20 @@ export enum LIST_OPTION_TYPE { SEGMENT = 'Segment', } +export enum LIST_OWNER_TYPE { + EXPERIMENT = 'experiment', + FEATURE_FLAG = 'featureFlag', + SEGMENT = 'segment', +} + +export interface ListDetailsOwner { + id: string; + name: string; + type: LIST_OWNER_TYPE; + segmentType?: SEGMENT_TYPE; + listEnabled?: boolean; +} + export const PRIVATE_SEGMENT_LIST_FORM_FIELDS = { LIST_TYPE: 'listType', SEGMENT: 'segment', diff --git a/packages/frontend/projects/upgrade/src/app/features/dashboard/dashboard-routing.module.ts b/packages/frontend/projects/upgrade/src/app/features/dashboard/dashboard-routing.module.ts index ff636dfdec..9fab058a9a 100644 --- a/packages/frontend/projects/upgrade/src/app/features/dashboard/dashboard-routing.module.ts +++ b/packages/frontend/projects/upgrade/src/app/features/dashboard/dashboard-routing.module.ts @@ -1,6 +1,7 @@ import { NgModule } from '@angular/core'; import { Routes, RouterModule } from '@angular/router'; import { DashboardRootComponent } from './dashboard-root/dashboard-root.component'; +import { LIST_OWNER_TYPE } from '../../core/segments/store/segments.model'; // Conditionally define segments routes based on the toggle const segmentsRoutes = [ @@ -12,6 +13,15 @@ const segmentsRoutes = [ title: 'app-header.title.segments', }, }, + { + path: 'segments/detail/:segmentId/list/:filterMode/:listId', + loadComponent: () => + import('./segments/pages/list-details-page/list-details-page.component').then((c) => c.ListDetailsPageComponent), + data: { + title: 'app-header.title.segments', + listOwnerType: LIST_OWNER_TYPE.SEGMENT, + }, + }, { path: 'segments/detail/:segmentId', loadComponent: () => @@ -44,6 +54,17 @@ const routes: Routes = [ title: 'app-header.title.experiments', }, }, + { + path: 'home/detail/:experimentId/list/:filterMode/:listId', + loadComponent: () => + import('./segments/pages/list-details-page/list-details-page.component').then( + (c) => c.ListDetailsPageComponent + ), + data: { + title: 'app-header.title.experiments', + listOwnerType: LIST_OWNER_TYPE.EXPERIMENT, + }, + }, { path: 'home/detail/:experimentId', loadComponent: () => @@ -83,6 +104,17 @@ const routes: Routes = [ title: 'app-header.title.feature-flag', }, }, + { + path: 'featureflags/detail/:flagId/list/:filterMode/:listId', + loadComponent: () => + import('./segments/pages/list-details-page/list-details-page.component').then( + (c) => c.ListDetailsPageComponent + ), + data: { + title: 'app-header.title.feature-flag', + listOwnerType: LIST_OWNER_TYPE.FEATURE_FLAG, + }, + }, { path: 'featureflags/detail/:flagId', loadComponent: () => diff --git a/packages/frontend/projects/upgrade/src/app/features/dashboard/experiments/pages/experiment-details-page/experiment-details-page-content/experiment-inclusions-section-card/experiment-inclusions-table/experiment-inclusions-table.component.html b/packages/frontend/projects/upgrade/src/app/features/dashboard/experiments/pages/experiment-details-page/experiment-details-page-content/experiment-inclusions-section-card/experiment-inclusions-table/experiment-inclusions-table.component.html index 4d778929cd..ef0ae10af0 100644 --- a/packages/frontend/projects/upgrade/src/app/features/dashboard/experiments/pages/experiment-details-page/experiment-details-page-content/experiment-inclusions-section-card/experiment-inclusions-table/experiment-inclusions-table.component.html +++ b/packages/frontend/projects/upgrade/src/app/features/dashboard/experiments/pages/experiment-details-page/experiment-details-page-content/experiment-inclusions-section-card/experiment-inclusions-table/experiment-inclusions-table.component.html @@ -1,6 +1,7 @@
(); tableType = LIST_FILTER_MODE.EXCLUSION; // Use EXCLUSION to hide enable column for experiments + listFilterMode = LIST_FILTER_MODE.INCLUSION; dataSource$ = this.experimentService.selectExperimentInclusions$; isLoading$ = this.experimentService.isLoadingExperiment$; diff --git a/packages/frontend/projects/upgrade/src/app/features/dashboard/segments/modals/edit-list-value-modal/edit-list-value-modal.component.html b/packages/frontend/projects/upgrade/src/app/features/dashboard/segments/modals/edit-list-value-modal/edit-list-value-modal.component.html new file mode 100644 index 0000000000..d71f6e5d51 --- /dev/null +++ b/packages/frontend/projects/upgrade/src/app/features/dashboard/segments/modals/edit-list-value-modal/edit-list-value-modal.component.html @@ -0,0 +1,20 @@ + +
+ + Value + + @if (valueControl.hasError('required')) { + Value is required. + } @else if (valueControl.hasError('duplicate')) { + This value already exists in the list. + } + +
+
diff --git a/packages/frontend/projects/upgrade/src/app/features/dashboard/segments/modals/edit-list-value-modal/edit-list-value-modal.component.ts b/packages/frontend/projects/upgrade/src/app/features/dashboard/segments/modals/edit-list-value-modal/edit-list-value-modal.component.ts new file mode 100644 index 0000000000..0c92d40fb8 --- /dev/null +++ b/packages/frontend/projects/upgrade/src/app/features/dashboard/segments/modals/edit-list-value-modal/edit-list-value-modal.component.ts @@ -0,0 +1,43 @@ +import { ChangeDetectionStrategy, Component, Inject } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormControl, ReactiveFormsModule, Validators } from '@angular/forms'; +import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatInputModule } from '@angular/material/input'; +import { CommonModalComponent } from '@shared-component-lib'; + +export interface EditListValueModalData { + value: string; + existingValues: string[]; +} + +@Component({ + selector: 'app-edit-list-value-modal', + imports: [CommonModule, ReactiveFormsModule, MatFormFieldModule, MatInputModule, CommonModalComponent], + templateUrl: './edit-list-value-modal.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class EditListValueModalComponent { + valueControl = new FormControl(this.data.value, { + nonNullable: true, + validators: [Validators.required, this.uniqueValueValidator.bind(this)], + }); + + constructor( + @Inject(MAT_DIALOG_DATA) public data: EditListValueModalData, + private dialogRef: MatDialogRef + ) {} + + private uniqueValueValidator(control: FormControl) { + const value = control.value.trim(); + return value !== this.data.value && this.data.existingValues.includes(value) ? { duplicate: true } : null; + } + + submit(): void { + if (this.valueControl.invalid) { + this.valueControl.markAsTouched(); + return; + } + this.dialogRef.close(this.valueControl.value.trim()); + } +} diff --git a/packages/frontend/projects/upgrade/src/app/features/dashboard/segments/modals/upsert-list-values-modal/upsert-list-values-modal.component.html b/packages/frontend/projects/upgrade/src/app/features/dashboard/segments/modals/upsert-list-values-modal/upsert-list-values-modal.component.html new file mode 100644 index 0000000000..85aa033770 --- /dev/null +++ b/packages/frontend/projects/upgrade/src/app/features/dashboard/segments/modals/upsert-list-values-modal/upsert-list-values-modal.component.html @@ -0,0 +1,60 @@ + +
+ @if (data.importOnly) { @if (!fileName || errorMessage) { +
+ +

+ {{ 'feature-flags.upsert-list-modal.import-csv.message.text' | translate }} + +

+
+ } @else { +
+ {{ fileName }} — {{ importedValues.length }} values + +
+ } @if (importDuplicateCount) { +
+ info_outline + {{ importDuplicateCount }} {{ importDuplicateCount === 1 ? 'duplicate was' : 'duplicates were' }} skipped. +
+ } +
+ + + Append to existing values + Replace existing values + +
+ } @else { + + Values + + +

Separate values with commas or new lines.

+ @if (exceedsValueLimit) { + A list can contain up to 3,000 values. + } } +
+
diff --git a/packages/frontend/projects/upgrade/src/app/features/dashboard/segments/modals/upsert-list-values-modal/upsert-list-values-modal.component.scss b/packages/frontend/projects/upgrade/src/app/features/dashboard/segments/modals/upsert-list-values-modal/upsert-list-values-modal.component.scss new file mode 100644 index 0000000000..128134f920 --- /dev/null +++ b/packages/frontend/projects/upgrade/src/app/features/dashboard/segments/modals/upsert-list-values-modal/upsert-list-values-modal.component.scss @@ -0,0 +1,67 @@ +.values-form { + display: flex; + flex-direction: column; + gap: 16px; + + mat-form-field { + width: 100%; + } +} + +.entry-hint { + margin: -8px 0 0; + color: var(--dark-grey); +} + +.drag-drop-container { + display: flex; + flex-direction: column; + row-gap: 2px; + + .import-message { + margin: 0; + text-indent: 18px; + } +} + +.duplicate-message { + display: flex; + align-items: center; + gap: 6px; + color: var(--dark-grey); + + mat-icon { + width: 18px; + height: 18px; + font-size: 18px; + } +} + +.error-message { + display: block; +} + +.file-summary { + display: flex; + align-items: center; + gap: 8px; + + .remove-file-button { + padding: 0; + border: 0; + background: transparent; + color: var(--dark-grey); + font-size: 18px; + font-weight: 400; + line-height: 1; + cursor: pointer; + } +} + +.error-message { + color: var(--red); +} + +.import-behavior-section { + padding: 4px 0; +} diff --git a/packages/frontend/projects/upgrade/src/app/features/dashboard/segments/modals/upsert-list-values-modal/upsert-list-values-modal.component.ts b/packages/frontend/projects/upgrade/src/app/features/dashboard/segments/modals/upsert-list-values-modal/upsert-list-values-modal.component.ts new file mode 100644 index 0000000000..7ff1e39f70 --- /dev/null +++ b/packages/frontend/projects/upgrade/src/app/features/dashboard/segments/modals/upsert-list-values-modal/upsert-list-values-modal.component.ts @@ -0,0 +1,137 @@ +import { ChangeDetectionStrategy, ChangeDetectorRef, Component, Inject } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatInputModule } from '@angular/material/input'; +import { MatRadioModule } from '@angular/material/radio'; +import { MatIconModule } from '@angular/material/icon'; +import { TranslateModule } from '@ngx-translate/core'; +import { CommonLearnMoreLinkComponent, CommonModalComponent } from '@shared-component-lib'; +import { CommonImportContainerComponent } from '@shared-component-lib/common-import-container/common-import-container.component'; +import { FILE_TYPE } from 'upgrade_types'; +import { + exceedsListValueLimit, + mergeUniqueListValues, + parseSingleColumnCSV, + splitListValues, +} from '../../../../../core/segments/list-values.utils'; + +export enum LIST_VALUES_UPDATE_MODE { + APPEND = 'append', + REPLACE = 'replace', +} + +export interface UpsertListValuesModalData { + importOnly?: boolean; + existingValues?: string[]; +} + +export interface UpsertListValuesModalResult { + values: string[]; + mode: LIST_VALUES_UPDATE_MODE; + fileName?: string; +} + +@Component({ + selector: 'app-upsert-list-values-modal', + imports: [ + CommonModule, + FormsModule, + MatFormFieldModule, + MatIconModule, + MatInputModule, + MatRadioModule, + TranslateModule, + CommonImportContainerComponent, + CommonLearnMoreLinkComponent, + CommonModalComponent, + ], + templateUrl: './upsert-list-values-modal.component.html', + styleUrl: './upsert-list-values-modal.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class UpsertListValuesModalComponent { + rawValues = ''; + importedValues: string[] = []; + importDuplicateCount = 0; + fileName = ''; + errorMessage = ''; + updateMode = LIST_VALUES_UPDATE_MODE.APPEND; + readonly UPDATE_MODE = LIST_VALUES_UPDATE_MODE; + readonly FILE_TYPE = FILE_TYPE; + + constructor( + @Inject(MAT_DIALOG_DATA) public data: UpsertListValuesModalData, + private dialogRef: MatDialogRef, + private changeDetectorRef: ChangeDetectorRef + ) {} + + get title(): string { + return this.data.importOnly ? 'Import Values from CSV' : 'Add Values'; + } + + get values(): string[] { + return this.data.importOnly ? this.importedValues : splitListValues(this.rawValues); + } + + get primaryActionLabel(): string { + return this.data.importOnly ? 'Import' : 'Add'; + } + + get exceedsValueLimit(): boolean { + if (this.data.importOnly) { + return false; + } + return exceedsListValueLimit(this.data.existingValues ?? [], this.values); + } + + get isPrimaryActionDisabled(): boolean { + return this.values.length === 0 || this.exceedsValueLimit || !!this.errorMessage; + } + + onFilesSelected(files: File[]): void { + const file = files[0]; + this.errorMessage = ''; + this.importedValues = []; + this.importDuplicateCount = 0; + this.fileName = file?.name ?? ''; + + if (!file) { + return; + } + + const reader = new FileReader(); + reader.onload = () => { + try { + const parsedValues = parseSingleColumnCSV(String(reader.result ?? '')); + const mergeResult = mergeUniqueListValues([], parsedValues); + this.importedValues = mergeResult.values; + this.importDuplicateCount = mergeResult.duplicateValues.length; + } catch (error) { + this.errorMessage = error instanceof Error ? error.message : 'Unable to read CSV file'; + } + this.changeDetectorRef.markForCheck(); + }; + reader.onerror = () => { + this.errorMessage = 'Unable to read CSV file'; + this.changeDetectorRef.markForCheck(); + }; + reader.readAsText(file); + } + + clearImportedFile(): void { + this.fileName = ''; + this.importedValues = []; + this.importDuplicateCount = 0; + this.errorMessage = ''; + } + + submit(): void { + if (this.isPrimaryActionDisabled) { + return; + } + + this.dialogRef.close({ values: this.values, mode: this.updateMode, fileName: this.fileName }); + } +} diff --git a/packages/frontend/projects/upgrade/src/app/features/dashboard/segments/modals/upsert-private-segment-list-modal/upsert-private-segment-list-modal.component.html b/packages/frontend/projects/upgrade/src/app/features/dashboard/segments/modals/upsert-private-segment-list-modal/upsert-private-segment-list-modal.component.html index b86673d567..2ed33f14e6 100644 --- a/packages/frontend/projects/upgrade/src/app/features/dashboard/segments/modals/upsert-private-segment-list-modal/upsert-private-segment-list-modal.component.html +++ b/packages/frontend/projects/upgrade/src/app/features/dashboard/segments/modals/upsert-private-segment-list-modal/upsert-private-segment-list-modal.component.html @@ -45,15 +45,6 @@ } @if (selectedListType && selectedListType !== LIST_TYPES.SEGMENT) { - Name diff --git a/packages/frontend/projects/upgrade/src/app/features/dashboard/segments/modals/upsert-private-segment-list-modal/upsert-private-segment-list-modal.component.ts b/packages/frontend/projects/upgrade/src/app/features/dashboard/segments/modals/upsert-private-segment-list-modal/upsert-private-segment-list-modal.component.ts index c1fccbf556..223aa080bd 100644 --- a/packages/frontend/projects/upgrade/src/app/features/dashboard/segments/modals/upsert-private-segment-list-modal/upsert-private-segment-list-modal.component.ts +++ b/packages/frontend/projects/upgrade/src/app/features/dashboard/segments/modals/upsert-private-segment-list-modal/upsert-private-segment-list-modal.component.ts @@ -1,5 +1,5 @@ import { ChangeDetectionStrategy, ChangeDetectorRef, Component, Inject, ViewChild } from '@angular/core'; -import { CommonModalComponent, CommonTagsInputComponent } from '@shared-component-lib'; +import { CommonModalComponent } from '@shared-component-lib'; import { MAT_DIALOG_DATA, MatDialog, MatDialogRef } from '@angular/material/dialog'; import { CommonModule } from '@angular/common'; import { @@ -14,7 +14,6 @@ import { import { MatFormFieldModule } from '@angular/material/form-field'; import { MatSelect, MatSelectModule } from '@angular/material/select'; import { CommonFormHelpersService } from '../../../../../shared/services/common-form-helpers.service'; -import { CommonExportHelpersService } from '../../../../../shared/services/common-export-helpers.service'; import { TranslateModule } from '@ngx-translate/core'; import { ExperimentService } from '../../../../../core/experiments/experiments.service'; import { MatInputModule } from '@angular/material/input'; @@ -51,7 +50,6 @@ import { SEGMENT_TYPE } from '../../../../../../../../../../types/src'; import isEqual from 'lodash.isequal'; import { FeatureFlagsService } from '../../../../../core/feature-flags/feature-flags.service'; import { CommonModalConfig } from '@shared-component-lib/common-modal/common-modal.types'; -import { CommonTagInputType } from '../../../../../core/feature-flags/store/feature-flags.model'; import { SharedModule } from '../../../../../shared/shared.module'; @Component({ @@ -62,7 +60,6 @@ import { SharedModule } from '../../../../../shared/shared.module'; MatFormFieldModule, MatInputModule, MatAutocompleteModule, - CommonTagsInputComponent, CommonModule, ReactiveFormsModule, TranslateModule, @@ -96,9 +93,6 @@ export class UpsertPrivateSegmentListModalComponent { isSegmentsListTypeDisabled$: Observable; privateSegmentListForm: FormGroup; - CommonTagInputType = CommonTagInputType; - forceValidation = false; - constructor( @Inject(MAT_DIALOG_DATA) public config: CommonModalConfig, @@ -107,7 +101,6 @@ export class UpsertPrivateSegmentListModalComponent { private segmentsService: SegmentsService, private experimentService: ExperimentService, private featureFlagService: FeatureFlagsService, - private commonExportHelpersService: CommonExportHelpersService, private changeDetectorRef: ChangeDetectorRef, public dialogRef: MatDialogRef ) {} @@ -153,6 +146,16 @@ export class UpsertPrivateSegmentListModalComponent { return this.privateSegmentListForm?.get(PRIVATE_SEGMENT_LIST_FORM_FIELDS.VALUES); } + get isEditAction(): boolean { + return [ + UPSERT_PRIVATE_SEGMENT_LIST_ACTION.EDIT_FLAG_INCLUDE_LIST, + UPSERT_PRIVATE_SEGMENT_LIST_ACTION.EDIT_FLAG_EXCLUDE_LIST, + UPSERT_PRIVATE_SEGMENT_LIST_ACTION.EDIT_EXPERIMENT_INCLUDE_LIST, + UPSERT_PRIVATE_SEGMENT_LIST_ACTION.EDIT_EXPERIMENT_EXCLUDE_LIST, + UPSERT_PRIVATE_SEGMENT_LIST_ACTION.EDIT_SEGMENT_LIST, + ].includes(this.config.params.action); + } + private segmentObjectValidator(): ValidatorFn { return (control: AbstractControl): ValidationErrors | null => { const value = control.value; @@ -193,15 +196,7 @@ export class UpsertPrivateSegmentListModalComponent { } populateFormForEdit(): void { - if ( - ![ - UPSERT_PRIVATE_SEGMENT_LIST_ACTION.EDIT_FLAG_INCLUDE_LIST, - UPSERT_PRIVATE_SEGMENT_LIST_ACTION.EDIT_FLAG_EXCLUDE_LIST, - UPSERT_PRIVATE_SEGMENT_LIST_ACTION.EDIT_EXPERIMENT_INCLUDE_LIST, - UPSERT_PRIVATE_SEGMENT_LIST_ACTION.EDIT_EXPERIMENT_EXCLUDE_LIST, - UPSERT_PRIVATE_SEGMENT_LIST_ACTION.EDIT_SEGMENT_LIST, - ].includes(this.config.params.action) - ) { + if (!this.isEditAction) { return; } @@ -211,6 +206,7 @@ export class UpsertPrivateSegmentListModalComponent { } this.applyEditFormValues(sourceList.listType, sourceList.segment); + this.privateSegmentListForm.get(PRIVATE_SEGMENT_LIST_FORM_FIELDS.LIST_TYPE).disable({ emitEvent: false }); // Lazy-load the full members when the (counts-only) source list didn't include them. if (this.segmentMembersNeedFetch(sourceList.listType, sourceList.segment)) { @@ -242,7 +238,7 @@ export class UpsertPrivateSegmentListModalComponent { const values = this.determineValues(listType, segment); const formValue: PrivateSegmentListFormData = { listType: listType as LIST_OPTION_TYPE, - segment, + segment: listType === LIST_OPTION_TYPE.SEGMENT ? segment.subSegments?.[0] : segment, values, name: segment.name, description: segment.description, @@ -306,8 +302,8 @@ export class UpsertPrivateSegmentListModalComponent { listenForIsInitialFormValueChanged() { this.isInitialFormValueChanged$ = this.privateSegmentListForm.valueChanges.pipe( - startWith(this.privateSegmentListForm.value), - map(() => !isEqual(this.privateSegmentListForm.value, this.initialFormValues$.value)) + startWith(this.privateSegmentListForm.getRawValue()), + map(() => !isEqual(this.privateSegmentListForm.getRawValue(), this.initialFormValues$.value)) ); this.subscriptions.add(this.isInitialFormValueChanged$.subscribe()); } @@ -366,7 +362,6 @@ export class UpsertPrivateSegmentListModalComponent { this.segmentObjectValidator(), ]); } else { - CommonFormHelpersService.setFieldValidators(this.privateSegmentListForm, valuesField, [Validators.required]); CommonFormHelpersService.setFieldValidators(this.privateSegmentListForm, nameField, [Validators.required]); } } @@ -377,7 +372,6 @@ export class UpsertPrivateSegmentListModalComponent { } onPrimaryActionBtnClicked(): void { - this.forceValidation = true; if (this.privateSegmentListForm.valid) { this.sendRequest(this.config.params.action); } else { @@ -387,7 +381,7 @@ export class UpsertPrivateSegmentListModalComponent { } sendRequest(action: UPSERT_PRIVATE_SEGMENT_LIST_ACTION): void { - const formData = this.privateSegmentListForm.value; + const formData = this.privateSegmentListForm.getRawValue(); const listType = formData.listType; const isExcludeList = [ UPSERT_PRIVATE_SEGMENT_LIST_ACTION.ADD_FLAG_EXCLUDE_LIST, @@ -532,14 +526,6 @@ export class UpsertPrivateSegmentListModalComponent { this.segmentsService.updatePrivateSegmentList(editListRequest); } - onDownloadRequested(values: string[]) { - if (this.privateSegmentListForm.get('name').valid) { - this.commonExportHelpersService.downloadValuesAsCSV(values, this.privateSegmentListForm.get('name').value); - } else { - this.privateSegmentListForm.get('name').markAsTouched(); - } - } - closeModal() { this.dialogRef.close(); } diff --git a/packages/frontend/projects/upgrade/src/app/features/dashboard/segments/pages/list-details-page/list-details-page.component.html b/packages/frontend/projects/upgrade/src/app/features/dashboard/segments/pages/list-details-page/list-details-page.component.html new file mode 100644 index 0000000000..9e2d1ac75b --- /dev/null +++ b/packages/frontend/projects/upgrade/src/app/features/dashboard/segments/pages/list-details-page/list-details-page.component.html @@ -0,0 +1,123 @@ + + + +
+ @if (isLoading) { + + } @if (list && owner) { + + + + + + + + + + +
+ + + @if (values.length) { + + } +
+ + + + @if (isValuesSectionExpanded) { +
+ @if (isSaving) { + + } + + + + + + + + + + + + + + + + +
Value{{ row.value }}@if (canManage) { Actions } + @if (canManage) { +
+ +
+
+ +
+ } +
+ {{ values.length ? 'No values match your search.' : 'No values yet. Add values or import a CSV.' }} +
+
+ } +
+
+ } +
+
diff --git a/packages/frontend/projects/upgrade/src/app/features/dashboard/segments/pages/list-details-page/list-details-page.component.scss b/packages/frontend/projects/upgrade/src/app/features/dashboard/segments/pages/list-details-page/list-details-page.component.scss new file mode 100644 index 0000000000..5a4ab6ff92 --- /dev/null +++ b/packages/frontend/projects/upgrade/src/app/features/dashboard/segments/pages/list-details-page/list-details-page.component.scss @@ -0,0 +1,92 @@ +.values-header { + display: flex; + align-items: center; + column-gap: 32px; +} + +.values-table-container { + position: relative; + overflow: auto; + width: 100%; + padding: 32px; + + ::ng-deep .no-data tbody:before { + display: block; + line-height: 8px; + content: '\200C'; + } +} + +.values-table { + width: 100%; + + ::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 { + padding-left: 32px; + 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); + + &:first-child { + padding-left: 32px; + } + } + } + + tr.mat-mdc-no-data-row { + height: 48px; + + td { + text-align: center; + border: 1.5px dashed var(--light-grey-2); + color: var(--dark-grey); + } + } + } + + .actions-column { + width: 10%; + min-width: 96px; + padding-right: 16px; + text-align: center; + + .button-wrapper { + display: inline-block; + + .action-button { + color: var(--dark-grey); + + &[disabled] { + .mat-icon { + opacity: 0.5; + } + } + } + } + } +} diff --git a/packages/frontend/projects/upgrade/src/app/features/dashboard/segments/pages/list-details-page/list-details-page.component.ts b/packages/frontend/projects/upgrade/src/app/features/dashboard/segments/pages/list-details-page/list-details-page.component.ts new file mode 100644 index 0000000000..07c631e430 --- /dev/null +++ b/packages/frontend/projects/upgrade/src/app/features/dashboard/segments/pages/list-details-page/list-details-page.component.ts @@ -0,0 +1,550 @@ +import { CommonModule } from '@angular/common'; +import { ChangeDetectionStrategy, ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core'; +import { MatButtonModule } from '@angular/material/button'; +import { MatDialog } from '@angular/material/dialog'; +import { MatIconModule } from '@angular/material/icon'; +import { MatProgressBarModule } from '@angular/material/progress-bar'; +import { MatTableDataSource, MatTableModule } from '@angular/material/table'; +import { ActivatedRoute, Router } from '@angular/router'; +import { + CommonDetailsPageHeaderComponent, + CommonPageComponent, + CommonSectionCardActionButtonsComponent, + CommonSectionCardComponent, + CommonSectionCardListComponent, + CommonSectionCardOverviewDetailsComponent, + CommonSectionCardSearchHeaderComponent, + CommonSectionCardTitleHeaderComponent, +} from '@shared-component-lib'; +import { KeyValueFormat } from '@shared-component-lib/common-section-card-overview-details/common-section-card-overview-details.component'; +import { CommonSearchWidgetSearchParams } from '@shared-component-lib/common-section-card-search-header/common-section-card-search-header.component'; +import { finalize, forkJoin, Subscription } from 'rxjs'; +import { IMenuButtonItem, LIST_FILTER_MODE, SEGMENT_TYPE } from 'upgrade_types'; +import { AuthService } from '../../../../../core/auth/auth.service'; +import { NotificationService } from '../../../../../core/core.module'; +import { ListDetailsDataService } from '../../../../../core/segments/list-details.data.service'; +import { + EditPrivateSegmentListDetails, + LIST_OPTION_TYPE, + LIST_OWNER_TYPE, + ListDetailsOwner, + ParticipantListTableRow, + Segment, +} from '../../../../../core/segments/store/segments.model'; +import { CommonExportHelpersService } from '../../../../../shared/services/common-export-helpers.service'; +import { DialogService } from '../../../../../shared/services/common-dialog.service'; +import { + CommonModalConfig, + ModalSize, + SimpleConfirmationModalParams, +} from '@shared-component-lib/common-modal/common-modal.types'; +import { MAX_LIST_VALUES, mergeUniqueListValues } from '../../../../../core/segments/list-values.utils'; +import { + LIST_VALUES_UPDATE_MODE, + UpsertListValuesModalComponent, + UpsertListValuesModalResult, +} from '../../modals/upsert-list-values-modal/upsert-list-values-modal.component'; +import { EditListValueModalComponent } from '../../modals/edit-list-value-modal/edit-list-value-modal.component'; + +interface ListValueTableRow { + index: number; + value: string; +} + +enum LIST_DETAILS_ACTION { + EDIT = 'edit', + DELETE = 'delete', + IMPORT = 'import', + EXPORT = 'export', +} + +@Component({ + selector: 'app-list-details-page', + imports: [ + CommonModule, + CommonPageComponent, + CommonDetailsPageHeaderComponent, + CommonSectionCardComponent, + CommonSectionCardListComponent, + CommonSectionCardOverviewDetailsComponent, + CommonSectionCardSearchHeaderComponent, + CommonSectionCardTitleHeaderComponent, + CommonSectionCardActionButtonsComponent, + MatButtonModule, + MatIconModule, + MatProgressBarModule, + MatTableModule, + ], + templateUrl: './list-details-page.component.html', + styleUrl: './list-details-page.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class ListDetailsPageComponent implements OnInit, OnDestroy { + readonly displayedColumns = ['value', 'actions']; + readonly dataSource = new MatTableDataSource([]); + ownerType: LIST_OWNER_TYPE; + ownerId = ''; + listId = ''; + filterMode = LIST_FILTER_MODE.EXCLUSION; + owner: ListDetailsOwner; + list: Segment; + listType = ''; + listEnabled = true; + values: string[] = []; + valuesSearchString = ''; + metadataMenuButtonItems: IMenuButtonItem[] = []; + valuesMenuButtonItems: IMenuButtonItem[] = []; + showMetadataMenuButton = false; + isValuesMenuDisabled = true; + isLoading = true; + isSaving = false; + canManage = false; + canDelete = false; + areSectionCardsExpanded = true; + isValuesSectionExpanded = true; + + private subscriptions = new Subscription(); + + constructor( + private route: ActivatedRoute, + private router: Router, + private listDetailsDataService: ListDetailsDataService, + private dialog: MatDialog, + private dialogService: DialogService, + private authService: AuthService, + private notificationService: NotificationService, + private commonExportHelpersService: CommonExportHelpersService, + private changeDetectorRef: ChangeDetectorRef + ) { + this.dataSource.filterPredicate = (row, filter) => row.value.toLowerCase().includes(filter); + } + + ngOnInit(): void { + this.ownerType = this.route.snapshot.data['listOwnerType']; + this.ownerId = this.getOwnerId(); + this.listId = this.route.snapshot.paramMap.get('listId') ?? ''; + this.filterMode = + (this.route.snapshot.paramMap.get('filterMode') as LIST_FILTER_MODE) ?? LIST_FILTER_MODE.EXCLUSION; + + this.subscriptions.add( + this.authService.userPermissions$.subscribe((permissions) => { + this.canManage = !!permissions?.[this.permissionKey]?.update; + this.canDelete = !!permissions?.[this.permissionKey]?.delete; + this.updateMetadataMenuButtonItems(); + this.updateValuesMenuButtonItems(); + this.changeDetectorRef.markForCheck(); + }) + ); + + this.loadDetails(); + } + + ngOnDestroy(): void { + this.subscriptions.unsubscribe(); + } + + get rootName(): string { + switch (this.ownerType) { + case LIST_OWNER_TYPE.EXPERIMENT: + return 'Experiments'; + case LIST_OWNER_TYPE.FEATURE_FLAG: + return 'Feature Flags'; + default: + return 'Segments'; + } + } + + get rootLink(): string { + switch (this.ownerType) { + case LIST_OWNER_TYPE.EXPERIMENT: + return 'home'; + case LIST_OWNER_TYPE.FEATURE_FLAG: + return 'featureflags'; + default: + return 'segments'; + } + } + + get parentLink(): any[] { + return ['/', this.rootLink, 'detail', this.ownerId]; + } + + get listSummarySubtitle(): string { + const filterLabel = this.filterMode === LIST_FILTER_MODE.INCLUSION ? 'Include' : 'Exclude'; + const typeLabel = + this.listType.toLowerCase() === LIST_OPTION_TYPE.INDIVIDUAL.toLowerCase() + ? LIST_OPTION_TYPE.INDIVIDUAL + : `Group: ${this.listType}`; + return `${filterLabel} · ${typeLabel}`; + } + + get listOverviewDetails(): KeyValueFormat { + return { + Description: this.list.description ?? '', + }; + } + + get permissionKey(): 'experiments' | 'featureFlags' | 'segments' { + switch (this.ownerType) { + case LIST_OWNER_TYPE.EXPERIMENT: + return 'experiments'; + case LIST_OWNER_TYPE.FEATURE_FLAG: + return 'featureFlags'; + default: + return 'segments'; + } + } + + loadDetails(): void { + if (!this.ownerId || !this.listId) { + return; + } + + this.isLoading = true; + this.subscriptions.add( + forkJoin({ + list: this.listDetailsDataService.fetchList(this.listId), + owner: this.listDetailsDataService.fetchOwner(this.ownerType, this.ownerId, this.filterMode, this.listId), + }) + .pipe( + finalize(() => { + this.isLoading = false; + this.changeDetectorRef.markForCheck(); + }) + ) + .subscribe({ + next: ({ list, owner }) => { + this.list = list; + this.owner = owner; + this.listType = list.listType ?? ''; + this.listEnabled = owner.listEnabled ?? this.filterMode === LIST_FILTER_MODE.EXCLUSION; + this.setValues(this.determineValues(list)); + this.updateMetadataMenuButtonItems(); + this.changeDetectorRef.markForCheck(); + }, + error: () => { + this.notificationService.showError('Unable to load list details.'); + this.changeDetectorRef.markForCheck(); + }, + }) + ); + } + + search(searchParams: CommonSearchWidgetSearchParams): void { + this.valuesSearchString = searchParams.searchString; + this.dataSource.filter = this.valuesSearchString.trim().toLowerCase(); + } + + openAddValuesModal(): void { + const dialogRef = this.dialog.open(UpsertListValuesModalComponent, { + data: { importOnly: false, existingValues: this.values }, + width: ModalSize.STANDARD, + autoFocus: 'textarea', + disableClose: true, + }); + this.subscriptions.add(dialogRef.afterClosed().subscribe((result) => this.applyValuesResult(result))); + } + + openImportValuesModal(): void { + const dialogRef = this.dialog.open(UpsertListValuesModalComponent, { + data: { importOnly: true, existingValues: this.values }, + width: ModalSize.STANDARD, + autoFocus: false, + disableClose: true, + }); + this.subscriptions.add(dialogRef.afterClosed().subscribe((result) => this.applyValuesResult(result))); + } + + exportValues(): void { + this.commonExportHelpersService.downloadValuesAsCSV(this.values, this.list.name || 'list-values'); + } + + onMetadataAction(action: string): void { + if (action === LIST_DETAILS_ACTION.EDIT) { + this.editMetadata(); + } else if (action === LIST_DETAILS_ACTION.DELETE) { + this.deleteList(); + } + } + + onOverviewSectionExpandChange(isExpanded: boolean): void { + this.areSectionCardsExpanded = isExpanded; + this.isValuesSectionExpanded = isExpanded; + } + + onValuesMenuAction(action: string): void { + if (action === LIST_DETAILS_ACTION.IMPORT) { + this.openImportValuesModal(); + } else if (action === LIST_DETAILS_ACTION.EXPORT) { + this.exportValues(); + } + } + + onValuesSectionExpandChange(isExpanded: boolean): void { + this.isValuesSectionExpanded = isExpanded; + } + + editValue(row: ListValueTableRow): void { + const dialogRef = this.dialog.open(EditListValueModalComponent, { + data: { value: row.value, existingValues: this.values }, + width: ModalSize.SMALL, + disableClose: true, + }); + this.subscriptions.add( + dialogRef.afterClosed().subscribe((value) => { + if (!value) { + return; + } + const nextValues = [...this.values]; + nextValues[row.index] = value; + this.saveValues(nextValues, 'Value updated.'); + }) + ); + } + + deleteValue(row: ListValueTableRow): void { + const config: CommonModalConfig = { + title: 'Delete Value', + primaryActionBtnLabel: 'Delete', + primaryActionBtnColor: 'warn', + cancelBtnLabel: 'Cancel', + params: { message: `Are you sure you want to delete "${row.value}"?` }, + }; + const dialogRef = this.dialogService.openSimpleCommonConfirmationModal(config, ModalSize.SMALL); + this.subscriptions.add( + dialogRef.afterClosed().subscribe((confirmed) => { + if (confirmed) { + this.saveValues( + this.values.filter((_, index) => index !== row.index), + 'Value deleted.' + ); + } + }) + ); + } + + editMetadata(): void { + const sourceList: ParticipantListTableRow = { + listType: this.listType, + segment: this.list, + enabled: this.listEnabled, + }; + let dialogRef; + + if (this.ownerType === LIST_OWNER_TYPE.EXPERIMENT) { + dialogRef = + this.filterMode === LIST_FILTER_MODE.INCLUSION + ? this.dialogService.openExperimentEditIncludeListModal(sourceList, this.list.context, this.ownerId) + : this.dialogService.openExperimentEditExcludeListModal(sourceList, this.list.context, this.ownerId); + } else if (this.ownerType === LIST_OWNER_TYPE.FEATURE_FLAG) { + dialogRef = + this.filterMode === LIST_FILTER_MODE.INCLUSION + ? this.dialogService.openFeatureFlagEditIncludeListModal(sourceList, this.list.context, this.ownerId) + : this.dialogService.openFeatureFlagEditExcludeListModal(sourceList, this.list.context, this.ownerId); + } else { + dialogRef = this.dialogService.openEditListModal( + sourceList, + this.list.context, + this.ownerId, + this.owner.segmentType + ); + } + + this.subscriptions.add(dialogRef.afterClosed().subscribe(() => this.loadDetails())); + } + + deleteList(): void { + let dialogRef; + if (this.ownerType === LIST_OWNER_TYPE.EXPERIMENT || this.ownerType === LIST_OWNER_TYPE.FEATURE_FLAG) { + dialogRef = + this.filterMode === LIST_FILTER_MODE.INCLUSION + ? this.dialogService.openDeleteIncludeListModal(this.list.name) + : this.dialogService.openDeleteExcludeListModal(this.list.name); + } else { + dialogRef = this.dialogService.openDeleteListModal(this.list.name, this.owner.segmentType); + } + + this.subscriptions.add( + dialogRef.afterClosed().subscribe((confirmed) => { + if (!confirmed) { + return; + } + this.isSaving = true; + this.subscriptions.add( + this.listDetailsDataService.deleteList(this.ownerType, this.filterMode, this.ownerId, this.listId).subscribe({ + next: () => { + this.notificationService.showSuccess('List deleted.'); + this.router.navigate(this.parentLink); + }, + error: () => { + this.isSaving = false; + this.notificationService.showError('Unable to delete list.'); + this.changeDetectorRef.markForCheck(); + }, + }) + ); + }) + ); + } + + private getOwnerId(): string { + switch (this.ownerType) { + case LIST_OWNER_TYPE.EXPERIMENT: + return this.route.snapshot.paramMap.get('experimentId') ?? ''; + case LIST_OWNER_TYPE.FEATURE_FLAG: + return this.route.snapshot.paramMap.get('flagId') ?? ''; + default: + return this.route.snapshot.paramMap.get('segmentId') ?? ''; + } + } + + private updateMetadataMenuButtonItems(): void { + const actionTarget = this.getMetadataActionTarget(); + this.metadataMenuButtonItems = [ + { + action: LIST_DETAILS_ACTION.EDIT, + disabled: !this.canManage, + label: `Edit ${actionTarget}`, + }, + { + action: LIST_DETAILS_ACTION.DELETE, + disabled: !this.canDelete, + label: `Delete ${actionTarget}`, + }, + ]; + this.showMetadataMenuButton = this.metadataMenuButtonItems.some((item) => !item.disabled); + } + + private getMetadataActionTarget(): string { + if (this.ownerType === LIST_OWNER_TYPE.SEGMENT && this.owner?.segmentType !== SEGMENT_TYPE.GLOBAL_EXCLUDE) { + return 'List'; + } + return this.filterMode === LIST_FILTER_MODE.INCLUSION ? 'Include List' : 'Exclude List'; + } + + private determineValues(list: Segment): string[] { + if (this.listType.toLowerCase() === LIST_OPTION_TYPE.INDIVIDUAL.toLowerCase()) { + return list.individualForSegment?.map((individual) => individual.userId) ?? []; + } + return list.groupForSegment?.map((group) => group.groupId) ?? []; + } + + private setValues(values: string[]): void { + this.values = values; + this.dataSource.data = values.map((value, index) => ({ value, index })); + this.updateValuesMenuButtonItems(); + } + + private updateValuesMenuButtonItems(): void { + this.valuesMenuButtonItems = [ + { + label: 'Import CSV', + action: LIST_DETAILS_ACTION.IMPORT, + disabled: !this.canManage, + preserveCase: true, + }, + { + label: 'Export CSV', + action: LIST_DETAILS_ACTION.EXPORT, + disabled: !this.values.length, + preserveCase: true, + }, + ]; + this.isValuesMenuDisabled = this.valuesMenuButtonItems.every((item) => item.disabled); + } + + private applyValuesResult(result?: UpsertListValuesModalResult): void { + if (!result) { + return; + } + + const mergeResult = + result.mode === LIST_VALUES_UPDATE_MODE.REPLACE + ? mergeUniqueListValues([], result.values) + : mergeUniqueListValues(this.values, result.values); + + if (mergeResult.values.length > MAX_LIST_VALUES) { + this.notificationService.showError(`A list can contain up to ${MAX_LIST_VALUES.toLocaleString()} values.`); + return; + } + + if (!mergeResult.addedValues.length && result.mode === LIST_VALUES_UPDATE_MODE.APPEND) { + this.notificationService.showInfo(this.getAddedValuesMessage(0, mergeResult.duplicateValues.length)); + return; + } + + if (result.mode === LIST_VALUES_UPDATE_MODE.REPLACE) { + this.saveValues( + mergeResult.values, + this.getReplacedValuesMessage(mergeResult.values.length, mergeResult.duplicateValues.length) + ); + return; + } + + this.saveValues( + mergeResult.values, + this.getAddedValuesMessage(mergeResult.addedValues.length, mergeResult.duplicateValues.length) + ); + } + + private getAddedValuesMessage(addedCount: number, duplicateCount: number): string { + const addedMessage = addedCount + ? `Added ${addedCount.toLocaleString()} ${addedCount === 1 ? 'value' : 'values'}.` + : 'No values were added.'; + return `${addedMessage}${this.getDuplicatesSkippedMessage(duplicateCount)}`; + } + + private getReplacedValuesMessage(valueCount: number, duplicateCount: number): string { + const replacedMessage = `Replaced the list with ${valueCount.toLocaleString()} ${ + valueCount === 1 ? 'value' : 'values' + }.`; + return `${replacedMessage}${this.getDuplicatesSkippedMessage(duplicateCount)}`; + } + + private getDuplicatesSkippedMessage(duplicateCount: number): string { + if (!duplicateCount) { + return ''; + } + return ` ${duplicateCount.toLocaleString()} ${duplicateCount === 1 ? 'duplicate was' : 'duplicates were'} skipped.`; + } + + private saveValues(values: string[], successMessage: string): void { + if (this.isSaving) { + return; + } + + const segment: EditPrivateSegmentListDetails = { + id: this.list.id, + name: this.list.name, + description: this.list.description ?? '', + context: this.list.context, + type: SEGMENT_TYPE.PRIVATE, + userIds: this.listType.toLowerCase() === LIST_OPTION_TYPE.INDIVIDUAL.toLowerCase() ? values : [], + groups: + this.listType.toLowerCase() === LIST_OPTION_TYPE.INDIVIDUAL.toLowerCase() + ? [] + : values.map((groupId) => ({ groupId, type: this.listType })), + subSegmentIds: [], + listType: this.listType, + }; + + this.isSaving = true; + this.listDetailsDataService + .updateList(this.ownerType, this.filterMode, this.ownerId, this.listEnabled, this.listType, segment) + .pipe( + finalize(() => { + this.isSaving = false; + this.changeDetectorRef.markForCheck(); + }) + ) + .subscribe({ + next: (updatedList) => { + this.list = { ...this.list, ...updatedList, listType: this.listType }; + this.setValues(values); + this.notificationService.showSuccess(successMessage); + this.changeDetectorRef.markForCheck(); + }, + error: () => this.notificationService.showError('Unable to update list values.'), + }); + } +} diff --git a/packages/frontend/projects/upgrade/src/app/shared-standalone-component-lib/components/common-details-page-header/common-details-page-header.component.html b/packages/frontend/projects/upgrade/src/app/shared-standalone-component-lib/components/common-details-page-header/common-details-page-header.component.html index d6fc1ec2a3..f767e45dae 100644 --- a/packages/frontend/projects/upgrade/src/app/shared-standalone-component-lib/components/common-details-page-header/common-details-page-header.component.html +++ b/packages/frontend/projects/upgrade/src/app/shared-standalone-component-lib/components/common-details-page-header/common-details-page-header.component.html @@ -1,7 +1,9 @@
{{ rootName | translate }} - > + > @if (parentName && parentLink) { + {{ parentName | translate }} + > } {{ detailsName | translate }}
diff --git a/packages/frontend/projects/upgrade/src/app/shared-standalone-component-lib/components/common-details-page-header/common-details-page-header.component.ts b/packages/frontend/projects/upgrade/src/app/shared-standalone-component-lib/components/common-details-page-header/common-details-page-header.component.ts index 24e0929e3c..d120ff7ec2 100644 --- a/packages/frontend/projects/upgrade/src/app/shared-standalone-component-lib/components/common-details-page-header/common-details-page-header.component.ts +++ b/packages/frontend/projects/upgrade/src/app/shared-standalone-component-lib/components/common-details-page-header/common-details-page-header.component.ts @@ -25,4 +25,6 @@ export class CommonDetailsPageHeaderComponent { @Input() rootName!: string; @Input() detailsName!: string; @Input() rootLink!: string; + @Input() parentName?: string; + @Input() parentLink?: any[]; } diff --git a/packages/frontend/projects/upgrade/src/app/shared-standalone-component-lib/components/common-details-participant-list-table/common-details-participant-list-table.component.html b/packages/frontend/projects/upgrade/src/app/shared-standalone-component-lib/components/common-details-participant-list-table/common-details-participant-list-table.component.html index 4a0ee1114e..7bcb3935dd 100644 --- a/packages/frontend/projects/upgrade/src/app/shared-standalone-component-lib/components/common-details-participant-list-table/common-details-participant-list-table.component.html +++ b/packages/frontend/projects/upgrade/src/app/shared-standalone-component-lib/components/common-details-participant-list-table/common-details-participant-list-table.component.html @@ -57,6 +57,23 @@ > {{ rowData.segment?.subSegments?.[0]?.description }} + } } @else if (isDirectValueList(rowData)) { + + {{ rowData.segment?.name }} + + @if (rowData.segment?.description) { + + {{ rowData.segment?.description }} + } } @else {
- @if (fileType === FILE_TYPE.CSV) { + @if (fileType === FILE_TYPE.CSV && showCloseButton) { diff --git a/packages/frontend/projects/upgrade/src/app/shared-standalone-component-lib/components/common-import-container/common-import-container.component.ts b/packages/frontend/projects/upgrade/src/app/shared-standalone-component-lib/components/common-import-container/common-import-container.component.ts index 238756a9b7..cfee4432f8 100644 --- a/packages/frontend/projects/upgrade/src/app/shared-standalone-component-lib/components/common-import-container/common-import-container.component.ts +++ b/packages/frontend/projects/upgrade/src/app/shared-standalone-component-lib/components/common-import-container/common-import-container.component.ts @@ -12,6 +12,7 @@ import { FILE_TYPE } from 'upgrade_types'; * The component accepts the following inputs: * - `fileType`: A string representing the accepted file type (e.g., '.json'). Only files with this extension can be selected or dropped. * - `buttonLabel`: A string representing the label text of the button. Defaults to 'Upload File'. + * - `showCloseButton`: Whether to show the CSV close button. Defaults to true. * * The component emits the following outputs: * - `closeButtonClick`: A mouse event when the close button is clicked (only used for CSV file type). @@ -38,6 +39,7 @@ export class CommonImportContainerComponent { @Input() fileType!: FILE_TYPE; @Input() buttonLabel!: string; @Input() importFailed = false; + @Input() showCloseButton = true; @Output() closeButtonClick = new EventEmitter(); @Output() filesSelected = new EventEmitter(); @ViewChild('fileInput') fileInput: ElementRef; diff --git a/packages/frontend/projects/upgrade/src/app/shared-standalone-component-lib/components/common-section-card-action-buttons/common-section-card-action-buttons.component.html b/packages/frontend/projects/upgrade/src/app/shared-standalone-component-lib/components/common-section-card-action-buttons/common-section-card-action-buttons.component.html index a79679c0ef..6c7db85e24 100644 --- a/packages/frontend/projects/upgrade/src/app/shared-standalone-component-lib/components/common-section-card-action-buttons/common-section-card-action-buttons.component.html +++ b/packages/frontend/projects/upgrade/src/app/shared-standalone-component-lib/components/common-section-card-action-buttons/common-section-card-action-buttons.component.html @@ -63,7 +63,9 @@ @for (item of menuButtonItems; track item) { @if (!item.disabled) { } } diff --git a/packages/frontend/projects/upgrade/src/app/shared-standalone-component-lib/components/common-section-card-search-header/common-section-card-search-header.component.html b/packages/frontend/projects/upgrade/src/app/shared-standalone-component-lib/components/common-section-card-search-header/common-section-card-search-header.component.html index 853c2b332a..583390b55e 100644 --- a/packages/frontend/projects/upgrade/src/app/shared-standalone-component-lib/components/common-section-card-search-header/common-section-card-search-header.component.html +++ b/packages/frontend/projects/upgrade/src/app/shared-standalone-component-lib/components/common-section-card-search-header/common-section-card-search-header.component.html @@ -1,4 +1,5 @@
+ @if (showFilterOptions) { @@ -20,7 +21,8 @@ } - + } + @if (!isDropdown) {
>(); standaloneOptions: FilterOption[] = []; diff --git a/packages/frontend/projects/upgrade/src/app/shared-standalone-component-lib/components/common-section-card-title-header/common-section-card-title-header.component.html b/packages/frontend/projects/upgrade/src/app/shared-standalone-component-lib/components/common-section-card-title-header/common-section-card-title-header.component.html index cb13bd320b..88b582dc10 100644 --- a/packages/frontend/projects/upgrade/src/app/shared-standalone-component-lib/components/common-section-card-title-header/common-section-card-title-header.component.html +++ b/packages/frontend/projects/upgrade/src/app/shared-standalone-component-lib/components/common-section-card-title-header/common-section-card-title-header.component.html @@ -1,8 +1,6 @@
- {{ title | translate }}  @if (tableRowCount > 0) { - ({{ tableRowCount }}) - } @if (chipClass) { + {{ title | translate }}{{ tableRowCount > 0 ? ' (' + tableRowCount + ')' : '' }}@if (chipClass) { }
+ @if (subtitle || (createdAt && updatedAt)) {

@if (subtitle) { @@ -34,7 +33,7 @@

} }

- @if (id) { + } @if (id) { ID: {{ id }} }
diff --git a/packages/types/src/Experiment/interfaces.ts b/packages/types/src/Experiment/interfaces.ts index 2376d51933..90f5e0703b 100644 --- a/packages/types/src/Experiment/interfaces.ts +++ b/packages/types/src/Experiment/interfaces.ts @@ -301,6 +301,7 @@ export interface IMenuButtonItem { action: string; label: string; // transalation key disabled: boolean; + preserveCase?: boolean; } export interface IImportFile { From 30f15ae5fc72e55fd00d64fbca663d7358b28052 Mon Sep 17 00:00:00 2001 From: Zack Lee Date: Thu, 20 Aug 2026 15:16:54 -0400 Subject: [PATCH 02/33] fix segment list details navigation race condition --- ...-tabbed-section-card-footer.component.html | 5 +- ...bbed-section-card-footer.component.spec.ts | 90 +++++++++++++++++++ 2 files changed, 94 insertions(+), 1 deletion(-) create mode 100644 packages/frontend/projects/upgrade/src/app/shared-standalone-component-lib/components/common-tabbed-section-card-footer/common-tabbed-section-card-footer.component.spec.ts diff --git a/packages/frontend/projects/upgrade/src/app/shared-standalone-component-lib/components/common-tabbed-section-card-footer/common-tabbed-section-card-footer.component.html b/packages/frontend/projects/upgrade/src/app/shared-standalone-component-lib/components/common-tabbed-section-card-footer/common-tabbed-section-card-footer.component.html index 4ce8b2701e..fe7a94ff79 100644 --- a/packages/frontend/projects/upgrade/src/app/shared-standalone-component-lib/components/common-tabbed-section-card-footer/common-tabbed-section-card-footer.component.html +++ b/packages/frontend/projects/upgrade/src/app/shared-standalone-component-lib/components/common-tabbed-section-card-footer/common-tabbed-section-card-footer.component.html @@ -7,7 +7,10 @@ (selectedTabChange)="onSelectedTabChange($event)" class="new-tab-group" > - @for (tab of tabLabels; track tab) { + + @for (tab of tabLabels; track tab.label) { diff --git a/packages/frontend/projects/upgrade/src/app/shared-standalone-component-lib/components/common-tabbed-section-card-footer/common-tabbed-section-card-footer.component.spec.ts b/packages/frontend/projects/upgrade/src/app/shared-standalone-component-lib/components/common-tabbed-section-card-footer/common-tabbed-section-card-footer.component.spec.ts new file mode 100644 index 0000000000..0bb0095426 --- /dev/null +++ b/packages/frontend/projects/upgrade/src/app/shared-standalone-component-lib/components/common-tabbed-section-card-footer/common-tabbed-section-card-footer.component.spec.ts @@ -0,0 +1,90 @@ +import { Component } from '@angular/core'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { ActivatedRoute, Router } from '@angular/router'; +import { provideNoopAnimations } from '@angular/platform-browser/animations'; +import { CommonTabbedSectionCardFooterComponent } from './common-tabbed-section-card-footer.component'; + +@Component({ + imports: [CommonTabbedSectionCardFooterComponent], + template: ``, +}) +class TestHostComponent { + tabLabels: { label: string; disabled?: boolean }[] = [{ label: 'Lists' }, { label: 'Used By' }]; + onSelectedTabChange = jest.fn(); +} + +describe('CommonTabbedSectionCardFooterComponent', () => { + let fixture: ComponentFixture; + let host: TestHostComponent; + let mockRouter: { navigate: jest.Mock }; + + const mockRoute = { + snapshot: { + queryParamMap: { + get: jest.fn().mockReturnValue(null), + }, + }, + } as unknown as ActivatedRoute; + + const flushMicrotasks = async () => { + await fixture.whenStable(); + fixture.detectChanges(); + await fixture.whenStable(); + }; + + beforeEach(async () => { + mockRouter = { navigate: jest.fn() }; + + await TestBed.configureTestingModule({ + imports: [TestHostComponent], + providers: [ + provideNoopAnimations(), + { provide: Router, useValue: mockRouter }, + { provide: ActivatedRoute, useValue: mockRoute }, + ], + }).compileComponents(); + + fixture = TestBed.createComponent(TestHostComponent); + host = fixture.componentInstance; + fixture.detectChanges(); + await flushMicrotasks(); + mockRouter.navigate.mockClear(); + host.onSelectedTabChange.mockClear(); + }); + + it('should create', () => { + expect(fixture.componentInstance).toBeTruthy(); + }); + + it('should not navigate when tabLabels is replaced with equal labels but new object identities', async () => { + // Simulates a details page re-emitting tab labels after a store update (e.g. a list + // was added and the parent entity was replaced). The rebuilt labels must not cause a + // programmatic selectedTabChange -> router.navigate, which would cancel an in-flight + // navigation such as the redirect to the new List Details page. + host.tabLabels = [{ label: 'Lists' }, { label: 'Used By' }]; + fixture.detectChanges(); + await flushMicrotasks(); + + expect(mockRouter.navigate).not.toHaveBeenCalled(); + }); + + it('should navigate with the tab query param when the user changes tabs', async () => { + const tabHeaders: NodeListOf = fixture.nativeElement.querySelectorAll('.mat-mdc-tab'); + expect(tabHeaders.length).toBe(2); + + tabHeaders[1].click(); + fixture.detectChanges(); + await flushMicrotasks(); + + expect(mockRouter.navigate).toHaveBeenCalledWith([], { + relativeTo: mockRoute, + queryParams: { tab: 1 }, + queryParamsHandling: 'merge', + replaceUrl: true, + }); + expect(host.onSelectedTabChange).toHaveBeenCalledWith(1); + }); +}); From 4162dfa255095cfbb5d992293eec8bbff829bdcb Mon Sep 17 00:00:00 2001 From: Zack Lee Date: Thu, 20 Aug 2026 15:47:00 -0400 Subject: [PATCH 03/33] fix list value validation and import limits --- .../repositories/ExperimentRepository.test.ts | 54 +++++++++++++++++++ .../edit-list-value-modal.component.ts | 3 ++ .../upsert-list-values-modal.component.html | 4 +- .../upsert-list-values-modal.component.scss | 5 +- .../upsert-list-values-modal.component.ts | 7 ++- .../list-details-page.component.ts | 3 +- ...etails-participant-list-table.component.ts | 2 +- ...on-section-card-search-header.component.ts | 2 +- 8 files changed, 67 insertions(+), 13 deletions(-) diff --git a/packages/backend/test/unit/repositories/ExperimentRepository.test.ts b/packages/backend/test/unit/repositories/ExperimentRepository.test.ts index ce6d2978bb..690bbb91fe 100644 --- a/packages/backend/test/unit/repositories/ExperimentRepository.test.ts +++ b/packages/backend/test/unit/repositories/ExperimentRepository.test.ts @@ -169,6 +169,33 @@ describe('ExperimentRepository Testing', () => { expect(res).toEqual(result); }); + it('should merge separately loaded inclusion and exclusion segment data', async () => { + const conditionData = { id: 'exp-a', name: 'Experiment A', conditions: ['condition'] } as any; + const factorData = { id: 'exp-a', partitions: ['partition'] } as any; + const metricData = { id: 'exp-a', queries: ['query'] } as any; + const inclusionData = { id: 'exp-a', experimentSegmentInclusion: ['inclusion'] } as any; + const exclusionData = { id: 'exp-a', experimentSegmentExclusion: ['exclusion'] } as any; + + mock.getMany + .mockResolvedValueOnce([conditionData]) + .mockResolvedValueOnce([factorData]) + .mockResolvedValueOnce([metricData]) + .mockResolvedValueOnce([inclusionData]) + .mockResolvedValueOnce([exclusionData]); + + const [res] = await repo.findAllExperiments(); + + expect(res).toMatchObject({ + id: 'exp-a', + name: 'Experiment A', + conditions: ['condition'], + partitions: ['partition'], + queries: ['query'], + experimentSegmentInclusion: ['inclusion'], + experimentSegmentExclusion: ['exclusion'], + }); + }); + it('should throw an error when find all experiments fails', async () => { mock.getMany.mockRejectedValue(err); @@ -383,6 +410,33 @@ describe('ExperimentRepository Testing', () => { expect(res).toEqual(experiment); }); + it('should merge separately loaded segment data when finding one experiment', async () => { + const conditionData = { id: 'exp-a', name: 'Experiment A', conditions: ['condition'] } as any; + const factorData = { id: 'exp-a', partitions: ['partition'] } as any; + const metricData = { id: 'exp-a', queries: ['query'] } as any; + const inclusionData = { id: 'exp-a', experimentSegmentInclusion: ['inclusion'] } as any; + const exclusionData = { id: 'exp-a', experimentSegmentExclusion: ['exclusion'] } as any; + + mock.getOne + .mockResolvedValueOnce(conditionData) + .mockResolvedValueOnce(factorData) + .mockResolvedValueOnce(metricData) + .mockResolvedValueOnce(inclusionData) + .mockResolvedValueOnce(exclusionData); + + const res = await repo.findOneExperiment('exp-a'); + + expect(res).toMatchObject({ + id: 'exp-a', + name: 'Experiment A', + conditions: ['condition'], + partitions: ['partition'], + queries: ['query'], + experimentSegmentInclusion: ['inclusion'], + experimentSegmentExclusion: ['exclusion'], + }); + }); + it('should clear the database', async () => { const entities = [ { diff --git a/packages/frontend/projects/upgrade/src/app/features/dashboard/segments/modals/edit-list-value-modal/edit-list-value-modal.component.ts b/packages/frontend/projects/upgrade/src/app/features/dashboard/segments/modals/edit-list-value-modal/edit-list-value-modal.component.ts index 0c92d40fb8..b6bdc4ff46 100644 --- a/packages/frontend/projects/upgrade/src/app/features/dashboard/segments/modals/edit-list-value-modal/edit-list-value-modal.component.ts +++ b/packages/frontend/projects/upgrade/src/app/features/dashboard/segments/modals/edit-list-value-modal/edit-list-value-modal.component.ts @@ -30,6 +30,9 @@ export class EditListValueModalComponent { private uniqueValueValidator(control: FormControl) { const value = control.value.trim(); + if (!value) { + return { required: true }; + } return value !== this.data.value && this.data.existingValues.includes(value) ? { duplicate: true } : null; } diff --git a/packages/frontend/projects/upgrade/src/app/features/dashboard/segments/modals/upsert-list-values-modal/upsert-list-values-modal.component.html b/packages/frontend/projects/upgrade/src/app/features/dashboard/segments/modals/upsert-list-values-modal/upsert-list-values-modal.component.html index 85aa033770..76e9c5288b 100644 --- a/packages/frontend/projects/upgrade/src/app/features/dashboard/segments/modals/upsert-list-values-modal/upsert-list-values-modal.component.html +++ b/packages/frontend/projects/upgrade/src/app/features/dashboard/segments/modals/upsert-list-values-modal/upsert-list-values-modal.component.html @@ -53,8 +53,8 @@ >

Separate values with commas or new lines.

- @if (exceedsValueLimit) { + } @if (exceedsValueLimit) { A list can contain up to 3,000 values. - } } + }
diff --git a/packages/frontend/projects/upgrade/src/app/features/dashboard/segments/modals/upsert-list-values-modal/upsert-list-values-modal.component.scss b/packages/frontend/projects/upgrade/src/app/features/dashboard/segments/modals/upsert-list-values-modal/upsert-list-values-modal.component.scss index 128134f920..5b04a6328b 100644 --- a/packages/frontend/projects/upgrade/src/app/features/dashboard/segments/modals/upsert-list-values-modal/upsert-list-values-modal.component.scss +++ b/packages/frontend/projects/upgrade/src/app/features/dashboard/segments/modals/upsert-list-values-modal/upsert-list-values-modal.component.scss @@ -39,6 +39,7 @@ .error-message { display: block; + color: var(--red); } .file-summary { @@ -58,10 +59,6 @@ } } -.error-message { - color: var(--red); -} - .import-behavior-section { padding: 4px 0; } diff --git a/packages/frontend/projects/upgrade/src/app/features/dashboard/segments/modals/upsert-list-values-modal/upsert-list-values-modal.component.ts b/packages/frontend/projects/upgrade/src/app/features/dashboard/segments/modals/upsert-list-values-modal/upsert-list-values-modal.component.ts index 7ff1e39f70..eb9b3459c1 100644 --- a/packages/frontend/projects/upgrade/src/app/features/dashboard/segments/modals/upsert-list-values-modal/upsert-list-values-modal.component.ts +++ b/packages/frontend/projects/upgrade/src/app/features/dashboard/segments/modals/upsert-list-values-modal/upsert-list-values-modal.component.ts @@ -80,10 +80,9 @@ export class UpsertListValuesModalComponent { } get exceedsValueLimit(): boolean { - if (this.data.importOnly) { - return false; - } - return exceedsListValueLimit(this.data.existingValues ?? [], this.values); + const existingValues = + this.data.importOnly && this.updateMode === LIST_VALUES_UPDATE_MODE.REPLACE ? [] : this.data.existingValues ?? []; + return exceedsListValueLimit(existingValues, this.values); } get isPrimaryActionDisabled(): boolean { diff --git a/packages/frontend/projects/upgrade/src/app/features/dashboard/segments/pages/list-details-page/list-details-page.component.ts b/packages/frontend/projects/upgrade/src/app/features/dashboard/segments/pages/list-details-page/list-details-page.component.ts index 07c631e430..dfc810ee74 100644 --- a/packages/frontend/projects/upgrade/src/app/features/dashboard/segments/pages/list-details-page/list-details-page.component.ts +++ b/packages/frontend/projects/upgrade/src/app/features/dashboard/segments/pages/list-details-page/list-details-page.component.ts @@ -249,7 +249,7 @@ export class ListDetailsPageComponent implements OnInit, OnDestroy { const dialogRef = this.dialog.open(UpsertListValuesModalComponent, { data: { importOnly: true, existingValues: this.values }, width: ModalSize.STANDARD, - autoFocus: false, + autoFocus: '.choose-file-btn', disableClose: true, }); this.subscriptions.add(dialogRef.afterClosed().subscribe((result) => this.applyValuesResult(result))); @@ -288,6 +288,7 @@ export class ListDetailsPageComponent implements OnInit, OnDestroy { const dialogRef = this.dialog.open(EditListValueModalComponent, { data: { value: row.value, existingValues: this.values }, width: ModalSize.SMALL, + autoFocus: 'input', disableClose: true, }); this.subscriptions.add( diff --git a/packages/frontend/projects/upgrade/src/app/shared-standalone-component-lib/components/common-details-participant-list-table/common-details-participant-list-table.component.ts b/packages/frontend/projects/upgrade/src/app/shared-standalone-component-lib/components/common-details-participant-list-table/common-details-participant-list-table.component.ts index b6618400b3..f56f06e1ae 100644 --- a/packages/frontend/projects/upgrade/src/app/shared-standalone-component-lib/components/common-details-participant-list-table/common-details-participant-list-table.component.ts +++ b/packages/frontend/projects/upgrade/src/app/shared-standalone-component-lib/components/common-details-participant-list-table/common-details-participant-list-table.component.ts @@ -122,7 +122,7 @@ export class CommonDetailsParticipantListTableComponent { } isDirectValueList(rowData: ParticipantListTableRow): boolean { - return rowData.listType?.toLowerCase() !== this.memberTypes.SEGMENT.toLowerCase(); + return !this.isSegmentListType(rowData); } get detailsFilterMode(): LIST_FILTER_MODE { diff --git a/packages/frontend/projects/upgrade/src/app/shared-standalone-component-lib/components/common-section-card-search-header/common-section-card-search-header.component.ts b/packages/frontend/projects/upgrade/src/app/shared-standalone-component-lib/components/common-section-card-search-header/common-section-card-search-header.component.ts index 9ebf8c8114..eec7259347 100644 --- a/packages/frontend/projects/upgrade/src/app/shared-standalone-component-lib/components/common-section-card-search-header/common-section-card-search-header.component.ts +++ b/packages/frontend/projects/upgrade/src/app/shared-standalone-component-lib/components/common-section-card-search-header/common-section-card-search-header.component.ts @@ -80,7 +80,7 @@ export class CommonSectionCardSearchHeaderComponent implements OnInit, OnChanges @Input() searchString: string; @Input() searchKey: string; @Input() showFilterOptions = true; - @Input() searchInputWidth = '240px'; + @Input() searchInputWidth?: string; @Output() search = new EventEmitter>(); standaloneOptions: FilterOption[] = []; From b540b016f32d49e52b479c3ba05ff6ed26a8e8ff Mon Sep 17 00:00:00 2001 From: Zack Lee Date: Thu, 20 Aug 2026 18:52:14 -0400 Subject: [PATCH 04/33] fix list details permissions and edge cases --- .../list-details.data.service.spec.ts | 54 +++++++++++++- .../segments/list-details.data.service.ts | 28 ++++++-- .../core/segments/list-values.utils.spec.ts | 10 +++ .../app/core/segments/list-values.utils.ts | 5 ++ .../app/core/segments/store/segments.model.ts | 5 ++ .../edit-list-value-modal.component.html | 2 + .../edit-list-value-modal.component.ts | 6 ++ ...rt-private-segment-list-modal.component.ts | 4 +- .../list-details-page.component.ts | 72 +++++++++++++------ 9 files changed, 152 insertions(+), 34 deletions(-) diff --git a/packages/frontend/projects/upgrade/src/app/core/segments/list-details.data.service.spec.ts b/packages/frontend/projects/upgrade/src/app/core/segments/list-details.data.service.spec.ts index 5d922b74dd..415a1be4d8 100644 --- a/packages/frontend/projects/upgrade/src/app/core/segments/list-details.data.service.spec.ts +++ b/packages/frontend/projects/upgrade/src/app/core/segments/list-details.data.service.spec.ts @@ -1,5 +1,5 @@ import { of } from 'rxjs'; -import { LIST_FILTER_MODE, SEGMENT_TYPE } from 'upgrade_types'; +import { EXPERIMENT_STATE, LIST_FILTER_MODE, SEGMENT_TYPE } from 'upgrade_types'; import { ExperimentDataService } from '../experiments/experiments.data.service'; import { FeatureFlagsDataService } from '../feature-flags/feature-flags.data.service'; import { ListDetailsDataService } from './list-details.data.service'; @@ -62,12 +62,12 @@ describe('ListDetailsDataService', () => { ); }); - it('loads a feature flag owner and preserves the include-list enabled state', (done) => { + it('loads a feature flag owner and preserves the include-list enabled state and list type', (done) => { featureFlagsDataService.fetchFeatureFlagById.mockReturnValue( of({ id: 'flag-id', name: 'Test flag', - featureFlagSegmentInclusion: [{ segment, enabled: true }], + featureFlagSegmentInclusion: [{ segment, enabled: true, listType: 'Individual' }], featureFlagSegmentExclusion: [], }) ); @@ -80,11 +80,59 @@ describe('ListDetailsDataService', () => { name: 'Test flag', type: LIST_OWNER_TYPE.FEATURE_FLAG, listEnabled: true, + listType: 'Individual', + }); + done(); + }); + }); + + it('loads an experiment owner with the inferred owner-side list type', (done) => { + experimentDataService.getExperimentById.mockReturnValue( + of({ + id: 'experiment-id', + name: 'Test experiment', + state: EXPERIMENT_STATE.ENROLLING, + // The experiment response carries the (possibly inferred) list type even when + // the list's own segment row predates the listType column. + experimentSegmentInclusion: [{ segment: { ...segment, listType: 'Individual' } }], + experimentSegmentExclusion: [], + }) + ); + + service + .fetchOwner(LIST_OWNER_TYPE.EXPERIMENT, 'experiment-id', LIST_FILTER_MODE.INCLUSION, segment.id) + .subscribe((owner) => { + expect(owner).toEqual({ + id: 'experiment-id', + name: 'Test experiment', + type: LIST_OWNER_TYPE.EXPERIMENT, + listType: 'Individual', + isReadOnly: false, }); done(); }); }); + it('marks completed and archived experiment owners as read-only', (done) => { + experimentDataService.getExperimentById.mockReturnValue( + of({ + id: 'experiment-id', + name: 'Test experiment', + state: EXPERIMENT_STATE.COMPLETED, + experimentSegmentInclusion: [], + experimentSegmentExclusion: [{ segment }], + }) + ); + + service + .fetchOwner(LIST_OWNER_TYPE.EXPERIMENT, 'experiment-id', LIST_FILTER_MODE.EXCLUSION, segment.id) + .subscribe((owner) => { + expect(owner.isReadOnly).toBe(true); + expect(owner.listType).toBe(segment.listType); + done(); + }); + }); + it('uses the experiment inclusion endpoint with the existing full-list payload', (done) => { experimentDataService.updateInclusionList.mockReturnValue(of({ segment })); diff --git a/packages/frontend/projects/upgrade/src/app/core/segments/list-details.data.service.ts b/packages/frontend/projects/upgrade/src/app/core/segments/list-details.data.service.ts index 8715e090e3..a83dab9bcb 100644 --- a/packages/frontend/projects/upgrade/src/app/core/segments/list-details.data.service.ts +++ b/packages/frontend/projects/upgrade/src/app/core/segments/list-details.data.service.ts @@ -1,6 +1,6 @@ import { Injectable } from '@angular/core'; import { Observable, map } from 'rxjs'; -import { LIST_FILTER_MODE } from 'upgrade_types'; +import { EXPERIMENT_STATE, LIST_FILTER_MODE } from 'upgrade_types'; import { ExperimentDataService } from '../experiments/experiments.data.service'; import { Experiment } from '../experiments/store/experiments.model'; import { FeatureFlagsDataService } from '../feature-flags/feature-flags.data.service'; @@ -36,11 +36,22 @@ export class ListDetailsDataService { switch (ownerType) { case LIST_OWNER_TYPE.EXPERIMENT: return this.experimentDataService.getExperimentById(ownerId).pipe( - map((experiment: Experiment) => ({ - id: experiment.id, - name: experiment.name, - type: ownerType, - })) + map((experiment: Experiment) => { + const lists = + filterMode === LIST_FILTER_MODE.INCLUSION + ? experiment.experimentSegmentInclusion + : experiment.experimentSegmentExclusion; + return { + id: experiment.id, + name: experiment.name, + type: ownerType, + // The experiment response carries the inferred list type for legacy lists + // whose own segment row predates the listType column. + listType: lists?.find((list) => list.segment?.id === listId)?.segment?.listType, + // The owner details page locks list changes for these states, so lock them here too. + isReadOnly: [EXPERIMENT_STATE.COMPLETED, EXPERIMENT_STATE.ARCHIVED].includes(experiment.state), + }; + }) ); case LIST_OWNER_TYPE.FEATURE_FLAG: return this.featureFlagsDataService.fetchFeatureFlagById(ownerId).pipe( @@ -49,11 +60,13 @@ export class ListDetailsDataService { filterMode === LIST_FILTER_MODE.INCLUSION ? featureFlag.featureFlagSegmentInclusion : featureFlag.featureFlagSegmentExclusion; + const list = lists?.find((entry) => entry.segment.id === listId); return { id: featureFlag.id, name: featureFlag.name, type: ownerType, - listEnabled: lists?.find((list) => list.segment.id === listId)?.enabled, + listEnabled: list?.enabled, + listType: list?.listType, }; }) ); @@ -64,6 +77,7 @@ export class ListDetailsDataService { name: response.segment.name, type: ownerType, segmentType: response.segment.type, + listType: response.segment.subSegments?.find((subSegment) => subSegment.id === listId)?.listType, })) ); } diff --git a/packages/frontend/projects/upgrade/src/app/core/segments/list-values.utils.spec.ts b/packages/frontend/projects/upgrade/src/app/core/segments/list-values.utils.spec.ts index 193d25b70e..567d1ee7c7 100644 --- a/packages/frontend/projects/upgrade/src/app/core/segments/list-values.utils.spec.ts +++ b/packages/frontend/projects/upgrade/src/app/core/segments/list-values.utils.spec.ts @@ -1,5 +1,6 @@ import { MAX_LIST_VALUES, + containsListValueSeparator, exceedsListValueLimit, mergeUniqueListValues, parseSingleColumnCSV, @@ -17,6 +18,15 @@ describe('list values utilities', () => { }); }); + describe('containsListValueSeparator', () => { + it('flags values that the add/import pipelines would split or reject', () => { + expect(containsListValueSeparator('schoolA,schoolB')).toBe(true); + expect(containsListValueSeparator('school\tA')).toBe(true); + expect(containsListValueSeparator('school\nA')).toBe(true); + expect(containsListValueSeparator('school-A_1')).toBe(false); + }); + }); + describe('mergeUniqueListValues', () => { it('keeps existing order and reports duplicate values', () => { expect(mergeUniqueListValues(['one', 'two'], ['two', 'three', 'three'])).toEqual({ diff --git a/packages/frontend/projects/upgrade/src/app/core/segments/list-values.utils.ts b/packages/frontend/projects/upgrade/src/app/core/segments/list-values.utils.ts index 7c95951448..fa7107a2c8 100644 --- a/packages/frontend/projects/upgrade/src/app/core/segments/list-values.utils.ts +++ b/packages/frontend/projects/upgrade/src/app/core/segments/list-values.utils.ts @@ -15,6 +15,11 @@ export function splitListValues(rawValue: string): string[] { .filter(Boolean); } +/** True when a single value contains characters that the add/import pipelines treat as separators. */ +export function containsListValueSeparator(value: string): boolean { + return VALUE_SEPARATORS.test(value); +} + export function mergeUniqueListValues(existingValues: string[], incomingValues: string[]): MergeListValuesResult { const seenValues = new Set(existingValues); const addedValues: string[] = []; diff --git a/packages/frontend/projects/upgrade/src/app/core/segments/store/segments.model.ts b/packages/frontend/projects/upgrade/src/app/core/segments/store/segments.model.ts index 0dad4bf1d2..058c05d322 100644 --- a/packages/frontend/projects/upgrade/src/app/core/segments/store/segments.model.ts +++ b/packages/frontend/projects/upgrade/src/app/core/segments/store/segments.model.ts @@ -322,6 +322,11 @@ export interface ListDetailsOwner { type: LIST_OWNER_TYPE; segmentType?: SEGMENT_TYPE; listEnabled?: boolean; + // Owner-side list type, used as a fallback when the list's own segment row predates + // the listType column (flag join rows store it; experiment responses infer it). + listType?: string; + // True when the owner disallows list changes (e.g. completed/archived experiments). + isReadOnly?: boolean; } export const PRIVATE_SEGMENT_LIST_FORM_FIELDS = { diff --git a/packages/frontend/projects/upgrade/src/app/features/dashboard/segments/modals/edit-list-value-modal/edit-list-value-modal.component.html b/packages/frontend/projects/upgrade/src/app/features/dashboard/segments/modals/edit-list-value-modal/edit-list-value-modal.component.html index d71f6e5d51..de1419cc06 100644 --- a/packages/frontend/projects/upgrade/src/app/features/dashboard/segments/modals/edit-list-value-modal/edit-list-value-modal.component.html +++ b/packages/frontend/projects/upgrade/src/app/features/dashboard/segments/modals/edit-list-value-modal/edit-list-value-modal.component.html @@ -12,6 +12,8 @@ @if (valueControl.hasError('required')) { Value is required. + } @else if (valueControl.hasError('separator')) { + Value cannot contain commas, tabs, or line breaks. } @else if (valueControl.hasError('duplicate')) { This value already exists in the list. } diff --git a/packages/frontend/projects/upgrade/src/app/features/dashboard/segments/modals/edit-list-value-modal/edit-list-value-modal.component.ts b/packages/frontend/projects/upgrade/src/app/features/dashboard/segments/modals/edit-list-value-modal/edit-list-value-modal.component.ts index b6bdc4ff46..64e969da88 100644 --- a/packages/frontend/projects/upgrade/src/app/features/dashboard/segments/modals/edit-list-value-modal/edit-list-value-modal.component.ts +++ b/packages/frontend/projects/upgrade/src/app/features/dashboard/segments/modals/edit-list-value-modal/edit-list-value-modal.component.ts @@ -5,6 +5,7 @@ import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; import { MatFormFieldModule } from '@angular/material/form-field'; import { MatInputModule } from '@angular/material/input'; import { CommonModalComponent } from '@shared-component-lib'; +import { containsListValueSeparator } from '../../../../../core/segments/list-values.utils'; export interface EditListValueModalData { value: string; @@ -33,6 +34,11 @@ export class EditListValueModalComponent { if (!value) { return { required: true }; } + // The add/import pipelines split on these characters, so a value containing them + // could not round-trip through paste or CSV export/import. + if (containsListValueSeparator(value)) { + return { separator: true }; + } return value !== this.data.value && this.data.existingValues.includes(value) ? { duplicate: true } : null; } diff --git a/packages/frontend/projects/upgrade/src/app/features/dashboard/segments/modals/upsert-private-segment-list-modal/upsert-private-segment-list-modal.component.ts b/packages/frontend/projects/upgrade/src/app/features/dashboard/segments/modals/upsert-private-segment-list-modal/upsert-private-segment-list-modal.component.ts index 8563a7dff8..a8645f7d19 100644 --- a/packages/frontend/projects/upgrade/src/app/features/dashboard/segments/modals/upsert-private-segment-list-modal/upsert-private-segment-list-modal.component.ts +++ b/packages/frontend/projects/upgrade/src/app/features/dashboard/segments/modals/upsert-private-segment-list-modal/upsert-private-segment-list-modal.component.ts @@ -46,7 +46,7 @@ import { Subscription, timer, } from 'rxjs'; -import { SEGMENT_TYPE } from '../../../../../../../../../../types/src'; +import { SEGMENT_TYPE } from 'upgrade_types'; import isEqual from 'lodash.isequal'; import { FeatureFlagsService } from '../../../../../core/feature-flags/feature-flags.service'; import { CommonModalConfig } from '@shared-component-lib/common-modal/common-modal.types'; @@ -375,7 +375,7 @@ export class UpsertPrivateSegmentListModalComponent { const listRequest: PrivateSegmentListRequest = { id: this.config.params.id, - enabled: this.config.params.sourceList?.enabled || isExcludeList, // Maintain existing status for edits, default to false for new include lists, true for all exclude lists + enabled: this.config.params.sourceList?.enabled ?? isExcludeList, // Maintain existing status for edits, default to false for new include lists, true for all exclude lists listType, segment: { ...list, listType }, }; diff --git a/packages/frontend/projects/upgrade/src/app/features/dashboard/segments/pages/list-details-page/list-details-page.component.ts b/packages/frontend/projects/upgrade/src/app/features/dashboard/segments/pages/list-details-page/list-details-page.component.ts index dfc810ee74..0030e44482 100644 --- a/packages/frontend/projects/upgrade/src/app/features/dashboard/segments/pages/list-details-page/list-details-page.component.ts +++ b/packages/frontend/projects/upgrade/src/app/features/dashboard/segments/pages/list-details-page/list-details-page.component.ts @@ -98,11 +98,12 @@ export class ListDetailsPageComponent implements OnInit, OnDestroy { isValuesMenuDisabled = true; isLoading = true; isSaving = false; - canManage = false; - canDelete = false; + isOwnerReadOnly = false; areSectionCardsExpanded = true; isValuesSectionExpanded = true; + private hasUpdatePermission = false; + private hasDeletePermission = false; private subscriptions = new Subscription(); constructor( @@ -124,12 +125,16 @@ export class ListDetailsPageComponent implements OnInit, OnDestroy { this.ownerId = this.getOwnerId(); this.listId = this.route.snapshot.paramMap.get('listId') ?? ''; this.filterMode = - (this.route.snapshot.paramMap.get('filterMode') as LIST_FILTER_MODE) ?? LIST_FILTER_MODE.EXCLUSION; + this.route.snapshot.paramMap.get('filterMode')?.toLowerCase() === LIST_FILTER_MODE.INCLUSION + ? LIST_FILTER_MODE.INCLUSION + : LIST_FILTER_MODE.EXCLUSION; this.subscriptions.add( + // List management is gated on segment permissions across all owner pages (see the + // inclusion/exclusion/lists section cards), so this page must match. this.authService.userPermissions$.subscribe((permissions) => { - this.canManage = !!permissions?.[this.permissionKey]?.update; - this.canDelete = !!permissions?.[this.permissionKey]?.delete; + this.hasUpdatePermission = !!permissions?.segments?.update; + this.hasDeletePermission = !!permissions?.segments?.delete; this.updateMetadataMenuButtonItems(); this.updateValuesMenuButtonItems(); this.changeDetectorRef.markForCheck(); @@ -170,11 +175,15 @@ export class ListDetailsPageComponent implements OnInit, OnDestroy { } get listSummarySubtitle(): string { - const filterLabel = this.filterMode === LIST_FILTER_MODE.INCLUSION ? 'Include' : 'Exclude'; const typeLabel = this.listType.toLowerCase() === LIST_OPTION_TYPE.INDIVIDUAL.toLowerCase() ? LIST_OPTION_TYPE.INDIVIDUAL : `Group: ${this.listType}`; + // Lists of a regular segment are plain member lists, not include/exclude lists. + if (this.isPlainSegmentList) { + return typeLabel; + } + const filterLabel = this.filterMode === LIST_FILTER_MODE.INCLUSION ? 'Include' : 'Exclude'; return `${filterLabel} · ${typeLabel}`; } @@ -184,15 +193,16 @@ export class ListDetailsPageComponent implements OnInit, OnDestroy { }; } - get permissionKey(): 'experiments' | 'featureFlags' | 'segments' { - switch (this.ownerType) { - case LIST_OWNER_TYPE.EXPERIMENT: - return 'experiments'; - case LIST_OWNER_TYPE.FEATURE_FLAG: - return 'featureFlags'; - default: - return 'segments'; - } + get canManage(): boolean { + return this.hasUpdatePermission && !this.isOwnerReadOnly; + } + + get canDelete(): boolean { + return this.hasDeletePermission && !this.isOwnerReadOnly; + } + + private get isPlainSegmentList(): boolean { + return this.ownerType === LIST_OWNER_TYPE.SEGMENT && this.owner?.segmentType !== SEGMENT_TYPE.GLOBAL_EXCLUDE; } loadDetails(): void { @@ -201,6 +211,7 @@ export class ListDetailsPageComponent implements OnInit, OnDestroy { } this.isLoading = true; + this.changeDetectorRef.markForCheck(); this.subscriptions.add( forkJoin({ list: this.listDetailsDataService.fetchList(this.listId), @@ -216,7 +227,8 @@ export class ListDetailsPageComponent implements OnInit, OnDestroy { next: ({ list, owner }) => { this.list = list; this.owner = owner; - this.listType = list.listType ?? ''; + this.isOwnerReadOnly = !!owner.isReadOnly; + this.listType = list.listType ?? owner.listType ?? ''; this.listEnabled = owner.listEnabled ?? this.filterMode === LIST_FILTER_MODE.EXCLUSION; this.setValues(this.determineValues(list)); this.updateMetadataMenuButtonItems(); @@ -325,6 +337,13 @@ export class ListDetailsPageComponent implements OnInit, OnDestroy { } editMetadata(): void { + // The edit modal saves a full member replacement keyed on the list type, so opening it + // without a known type could wipe the list's real members (same guard as saveValues). + if (!this.listType) { + this.notificationService.showError('Unable to edit this list because the list type is unknown.'); + return; + } + const sourceList: ParticipantListTableRow = { listType: this.listType, segment: this.list, @@ -371,6 +390,7 @@ export class ListDetailsPageComponent implements OnInit, OnDestroy { return; } this.isSaving = true; + this.changeDetectorRef.markForCheck(); this.subscriptions.add( this.listDetailsDataService.deleteList(this.ownerType, this.filterMode, this.ownerId, this.listId).subscribe({ next: () => { @@ -417,7 +437,7 @@ export class ListDetailsPageComponent implements OnInit, OnDestroy { } private getMetadataActionTarget(): string { - if (this.ownerType === LIST_OWNER_TYPE.SEGMENT && this.owner?.segmentType !== SEGMENT_TYPE.GLOBAL_EXCLUDE) { + if (this.isPlainSegmentList) { return 'List'; } return this.filterMode === LIST_FILTER_MODE.INCLUSION ? 'Include List' : 'Exclude List'; @@ -514,22 +534,28 @@ export class ListDetailsPageComponent implements OnInit, OnDestroy { return; } + // Without a known list type the update payload can't be built safely; a full-replacement + // save with the wrong member kind would wipe the list's real members. + if (!this.listType) { + this.notificationService.showError('Unable to update list values because the list type is unknown.'); + return; + } + + const isIndividualList = this.listType.toLowerCase() === LIST_OPTION_TYPE.INDIVIDUAL.toLowerCase(); const segment: EditPrivateSegmentListDetails = { id: this.list.id, name: this.list.name, description: this.list.description ?? '', context: this.list.context, type: SEGMENT_TYPE.PRIVATE, - userIds: this.listType.toLowerCase() === LIST_OPTION_TYPE.INDIVIDUAL.toLowerCase() ? values : [], - groups: - this.listType.toLowerCase() === LIST_OPTION_TYPE.INDIVIDUAL.toLowerCase() - ? [] - : values.map((groupId) => ({ groupId, type: this.listType })), + userIds: isIndividualList ? values : [], + groups: isIndividualList ? [] : values.map((groupId) => ({ groupId, type: this.listType })), subSegmentIds: [], listType: this.listType, }; this.isSaving = true; + this.changeDetectorRef.markForCheck(); this.listDetailsDataService .updateList(this.ownerType, this.filterMode, this.ownerId, this.listEnabled, this.listType, segment) .pipe( @@ -540,6 +566,8 @@ export class ListDetailsPageComponent implements OnInit, OnDestroy { ) .subscribe({ next: (updatedList) => { + // The update response re-fetches the segment with its member relations, so this + // keeps this.list (the metadata edit modal's full-replacement source) up to date. this.list = { ...this.list, ...updatedList, listType: this.listType }; this.setValues(values); this.notificationService.showSuccess(successMessage); From 999449e3f727bcf1c878764c7fad8595124cacf0 Mon Sep 17 00:00:00 2001 From: Zack Lee Date: Fri, 21 Aug 2026 13:32:32 -0400 Subject: [PATCH 05/33] fix list ownership validation and scoped deletion --- .../api/controllers/ExperimentController.ts | 31 ++++++- .../api/controllers/FeatureFlagController.ts | 35 +++++++- .../validators/ListOwnerInputValidator.ts | 7 ++ .../src/api/services/ExperimentService.ts | 8 +- .../src/api/services/FeatureFlagService.ts | 24 ++++-- .../GroupExperimentExclusionCode.ts | 1 + .../controllers/ExperimentController.test.ts | 2 + .../controllers/FeatureFlagController.test.ts | 2 + .../unit/services/ExperimentService.test.ts | 45 ++++++++++- .../unit/services/FeatureFlagService.test.ts | 19 ++++- .../experiments.data.service.spec.ts | 22 +++++ .../experiments/experiments.data.service.ts | 8 +- .../core/experiments/experiments.service.ts | 8 +- .../experiments/store/experiments.actions.ts | 4 +- .../experiments/store/experiments.effects.ts | 10 +-- .../feature-flags.data.service.ts | 8 +- .../feature-flags/feature-flags.service.ts | 8 +- .../store/feature-flags.actions.ts | 4 +- .../store/feature-flags.effects.ts | 10 +-- .../list-details.data.service.spec.ts | 80 ++++++++++++++++++- .../segments/list-details.data.service.ts | 67 ++++++++++++---- ...iment-exclusions-section-card.component.ts | 6 +- ...iment-inclusions-section-card.component.ts | 6 +- ...-flag-exclusions-section-card.component.ts | 6 +- ...-flag-inclusions-section-card.component.ts | 6 +- .../list-details-page.component.ts | 8 +- postman/PlatformAPI.postman_collection.json | 8 ++ 27 files changed, 360 insertions(+), 83 deletions(-) create mode 100644 packages/backend/src/api/controllers/validators/ListOwnerInputValidator.ts diff --git a/packages/backend/src/api/controllers/ExperimentController.ts b/packages/backend/src/api/controllers/ExperimentController.ts index fa4cb0a5ca..a874b528ae 100644 --- a/packages/backend/src/api/controllers/ExperimentController.ts +++ b/packages/backend/src/api/controllers/ExperimentController.ts @@ -49,6 +49,7 @@ import { Segment } from '../models/Segment'; import { MoocletRewardsService } from '../services/MoocletRewardsService'; import { ExperimentRewardsSummary } from 'upgrade_types'; import { CacheService } from '../services/CacheService'; +import { ListOwnerInputValidator } from './validators/ListOwnerInputValidator'; interface ExperimentPaginationInfo extends PaginationResponse { nodes: Experiment[]; @@ -1747,6 +1748,18 @@ export class ExperimentController { * schema: * type: string * description: Segment Id of private segment + * - in: body + * name: owner + * required: true + * schema: + * type: object + * required: + * - ownerId + * properties: + * ownerId: + * type: string + * format: uuid + * description: Experiment that owns the list * tags: * - Experiments * produces: @@ -1758,10 +1771,11 @@ export class ExperimentController { @Delete('/inclusionList/:id') public async deleteInclusionList( @Params({ validate: true }) { id }: IdValidator, + @Body({ validate: true }) { ownerId }: ListOwnerInputValidator, @CurrentUser() currentUser: UserDTO, @Req() request: AppRequest ): Promise { - return this.experimentService.deleteList(id, LIST_FILTER_MODE.INCLUSION, currentUser, request.logger); + return this.experimentService.deleteList(id, ownerId, LIST_FILTER_MODE.INCLUSION, currentUser, request.logger); } /** @@ -1778,6 +1792,18 @@ export class ExperimentController { * schema: * type: string * description: Segment Id of private segment + * - in: body + * name: owner + * required: true + * schema: + * type: object + * required: + * - ownerId + * properties: + * ownerId: + * type: string + * format: uuid + * description: Experiment that owns the list * tags: * - Experiments * produces: @@ -1789,10 +1815,11 @@ export class ExperimentController { @Delete('/exclusionList/:id') public async deleteExclusionList( @Params({ validate: true }) { id }: IdValidator, + @Body({ validate: true }) { ownerId }: ListOwnerInputValidator, @CurrentUser() currentUser: UserDTO, @Req() request: AppRequest ): Promise { - return this.experimentService.deleteList(id, LIST_FILTER_MODE.EXCLUSION, currentUser, request.logger); + return this.experimentService.deleteList(id, ownerId, LIST_FILTER_MODE.EXCLUSION, currentUser, request.logger); } /** diff --git a/packages/backend/src/api/controllers/FeatureFlagController.ts b/packages/backend/src/api/controllers/FeatureFlagController.ts index 10c74754d5..078f27e715 100644 --- a/packages/backend/src/api/controllers/FeatureFlagController.ts +++ b/packages/backend/src/api/controllers/FeatureFlagController.ts @@ -36,6 +36,7 @@ import { Response } from 'express'; import { UserDTO } from '../DTO/UserDTO'; import { NotFoundException } from '@nestjs/common/exceptions'; import { SegmentInputValidator } from './validators/SegmentInputValidator'; +import { ListOwnerInputValidator } from './validators/ListOwnerInputValidator'; interface FeatureFlagsPaginationInfo extends PaginationResponse { nodes: FeatureFlag[]; @@ -724,7 +725,7 @@ export class FeatureFlagsController { /** * @swagger - * /flags/inclusionList: + * /flags/inclusionList/{id}: * delete: * description: Delete Feature Flag Inclusion List * consumes: @@ -736,6 +737,18 @@ export class FeatureFlagsController { * schema: * type: string * description: Segment Id of private segment + * - in: body + * name: owner + * required: true + * schema: + * type: object + * required: + * - ownerId + * properties: + * ownerId: + * type: string + * format: uuid + * description: Feature flag that owns the list * tags: * - Feature Flags * produces: @@ -747,15 +760,16 @@ export class FeatureFlagsController { @Delete('/inclusionList/:id') public async deleteInclusionList( @Params({ validate: true }) { id }: IdValidator, + @Body({ validate: true }) { ownerId }: ListOwnerInputValidator, @CurrentUser() currentUser: UserDTO, @Req() request: AppRequest ): Promise { - return this.featureFlagService.deleteList(id, LIST_FILTER_MODE.INCLUSION, currentUser, request.logger); + return this.featureFlagService.deleteList(id, ownerId, LIST_FILTER_MODE.INCLUSION, currentUser, request.logger); } /** * @swagger - * /flags/exclusionList: + * /flags/exclusionList/{id}: * delete: * description: Delete Feature Flag Exclusion List * consumes: @@ -767,6 +781,18 @@ export class FeatureFlagsController { * schema: * type: string * description: Segment Id of private segment + * - in: body + * name: owner + * required: true + * schema: + * type: object + * required: + * - ownerId + * properties: + * ownerId: + * type: string + * format: uuid + * description: Feature flag that owns the list * tags: * - Feature Flags * produces: @@ -778,10 +804,11 @@ export class FeatureFlagsController { @Delete('/exclusionList/:id') public async deleteExclusionList( @Params({ validate: true }) { id }: IdValidator, + @Body({ validate: true }) { ownerId }: ListOwnerInputValidator, @CurrentUser() currentUser: UserDTO, @Req() request: AppRequest ): Promise { - return this.featureFlagService.deleteList(id, LIST_FILTER_MODE.EXCLUSION, currentUser, request.logger); + return this.featureFlagService.deleteList(id, ownerId, LIST_FILTER_MODE.EXCLUSION, currentUser, request.logger); } /** diff --git a/packages/backend/src/api/controllers/validators/ListOwnerInputValidator.ts b/packages/backend/src/api/controllers/validators/ListOwnerInputValidator.ts new file mode 100644 index 0000000000..ecd490cfcf --- /dev/null +++ b/packages/backend/src/api/controllers/validators/ListOwnerInputValidator.ts @@ -0,0 +1,7 @@ +import { IsNotEmpty, IsUUID } from 'class-validator'; + +export class ListOwnerInputValidator { + @IsNotEmpty() + @IsUUID() + public ownerId: string; +} diff --git a/packages/backend/src/api/services/ExperimentService.ts b/packages/backend/src/api/services/ExperimentService.ts index 8d0352d905..7d72f49519 100644 --- a/packages/backend/src/api/services/ExperimentService.ts +++ b/packages/backend/src/api/services/ExperimentService.ts @@ -2122,15 +2122,17 @@ export class ExperimentService { public async deleteList( segmentId: string, + experimentId: string, filterType: LIST_FILTER_MODE, currentUser: UserDTO, logger: UpgradeLogger ): Promise { const existingRecords = await this.getExistingInclusionExclusionSegments([segmentId], filterType); - if (existingRecords.length === 0) { - throw new Error(`Segment with ID ${segmentId} not found for ${filterType}`); + const existingRecord = existingRecords.find((record) => record.experiment.id === experimentId); + if (!existingRecord) { + throw new Error(`Segment with ID ${segmentId} not found for experiment ${experimentId} and ${filterType}`); } - await this.createDeleteListAuditLogs(existingRecords, filterType, currentUser); + await this.createDeleteListAuditLogs([existingRecord], filterType, currentUser); await this.cacheService.resetPrefixCache(CACHE_PREFIX.FEATURE_FLAG_KEY_PREFIX); return this.segmentService.deleteSegment(segmentId, logger); } diff --git a/packages/backend/src/api/services/FeatureFlagService.ts b/packages/backend/src/api/services/FeatureFlagService.ts index 2889fe8469..ba1e51bd1c 100644 --- a/packages/backend/src/api/services/FeatureFlagService.ts +++ b/packages/backend/src/api/services/FeatureFlagService.ts @@ -544,13 +544,17 @@ export class FeatureFlagService { // Create delete audit logs for inclusion and exclusion lists if (includeListIds.length) { promises.push( - this.createDeleteListAuditLogs(includeListIds, LIST_FILTER_MODE.INCLUSION, user, transactionalEntityManager) + this.createDeleteListAuditLogs(includeListIds, LIST_FILTER_MODE.INCLUSION, user, { + entityManager: transactionalEntityManager, + }) ); } if (excludeListIds.length) { promises.push( - this.createDeleteListAuditLogs(excludeListIds, LIST_FILTER_MODE.EXCLUSION, user, transactionalEntityManager) + this.createDeleteListAuditLogs(excludeListIds, LIST_FILTER_MODE.EXCLUSION, user, { + entityManager: transactionalEntityManager, + }) ); } @@ -604,11 +608,12 @@ export class FeatureFlagService { public async deleteList( segmentId: string, + featureFlagId: string, filterType: LIST_FILTER_MODE, currentUser: UserDTO, logger: UpgradeLogger ): Promise { - await this.createDeleteListAuditLogs([segmentId], filterType, currentUser); + await this.createDeleteListAuditLogs([segmentId], filterType, currentUser, { featureFlagId }); await this.cacheService.resetPrefixCache(CACHE_PREFIX.FEATURE_FLAG_KEY_PREFIX); // segmentService.deleteSegment collects the affected flags before deletion and fires the @@ -620,8 +625,9 @@ export class FeatureFlagService { segmentIds: string[], filterType: LIST_FILTER_MODE, currentUser: UserDTO, - entityManager?: EntityManager + options: { entityManager?: EntityManager; featureFlagId?: string } = {} ): Promise { + const { entityManager, featureFlagId } = options; const auditLogPromises = []; for (const segmentId of segmentIds) { @@ -629,7 +635,10 @@ export class FeatureFlagService { if (filterType === LIST_FILTER_MODE.INCLUSION) { existingRecord = await this.featureFlagSegmentInclusionRepository.findOne({ - where: { segment: { id: segmentId } }, + where: { + segment: { id: segmentId }, + ...(featureFlagId ? { featureFlag: { id: featureFlagId } } : {}), + }, relations: { featureFlag: true, segment: true, @@ -637,7 +646,10 @@ export class FeatureFlagService { }); } else { existingRecord = await this.featureFlagSegmentExclusionRepository.findOne({ - where: { segment: { id: segmentId } }, + where: { + segment: { id: segmentId }, + ...(featureFlagId ? { featureFlag: { id: featureFlagId } } : {}), + }, relations: { featureFlag: true, segment: true, diff --git a/packages/backend/test/integration/Experiment/exclusionCode/GroupExperimentExclusionCode.ts b/packages/backend/test/integration/Experiment/exclusionCode/GroupExperimentExclusionCode.ts index 6b4d87e286..5b717cc597 100644 --- a/packages/backend/test/integration/Experiment/exclusionCode/GroupExperimentExclusionCode.ts +++ b/packages/backend/test/integration/Experiment/exclusionCode/GroupExperimentExclusionCode.ts @@ -105,6 +105,7 @@ export default async function testCase(): Promise { await experimentService.deleteList( experimentObject.experimentSegmentExclusion[0].segment.id, + experimentId, LIST_FILTER_MODE.EXCLUSION, user, new UpgradeLogger() diff --git a/packages/backend/test/unit/controllers/ExperimentController.test.ts b/packages/backend/test/unit/controllers/ExperimentController.test.ts index f5990855f0..3ca0704731 100644 --- a/packages/backend/test/unit/controllers/ExperimentController.test.ts +++ b/packages/backend/test/unit/controllers/ExperimentController.test.ts @@ -305,6 +305,7 @@ describe('Experiment Controller Testing', () => { test('Delete request for /api/experiments/inclusionList/id', () => { return request(app) .delete('/api/experiments/inclusionList/' + crypto.randomUUID()) + .send({ ownerId: crypto.randomUUID() }) .set('Accept', 'application/json') .expect('Content-Type', /json/) .expect(200); @@ -313,6 +314,7 @@ describe('Experiment Controller Testing', () => { test('Delete request for /api/experiments/exclusionList/id', () => { return request(app) .delete('/api/experiments/exclusionList/' + crypto.randomUUID()) + .send({ ownerId: crypto.randomUUID() }) .set('Accept', 'application/json') .expect('Content-Type', /json/) .expect(200); diff --git a/packages/backend/test/unit/controllers/FeatureFlagController.test.ts b/packages/backend/test/unit/controllers/FeatureFlagController.test.ts index c09f639acc..9dfbb18bf5 100644 --- a/packages/backend/test/unit/controllers/FeatureFlagController.test.ts +++ b/packages/backend/test/unit/controllers/FeatureFlagController.test.ts @@ -180,6 +180,7 @@ describe('Feature Flag Controller Testing', () => { test('Delete request for /api/flags/inclusionList/id', () => { return request(app) .delete('/api/flags/inclusionList/' + crypto.randomUUID()) + .send({ ownerId: crypto.randomUUID() }) .set('Accept', 'application/json') .expect('Content-Type', /json/) .expect(200); @@ -188,6 +189,7 @@ describe('Feature Flag Controller Testing', () => { test('Delete request for /api/flags/exclusionList/id', () => { return request(app) .delete('/api/flags/exclusionList/' + crypto.randomUUID()) + .send({ ownerId: crypto.randomUUID() }) .set('Accept', 'application/json') .expect('Content-Type', /json/) .expect(200); diff --git a/packages/backend/test/unit/services/ExperimentService.test.ts b/packages/backend/test/unit/services/ExperimentService.test.ts index fcb83fb207..67cfe67517 100644 --- a/packages/backend/test/unit/services/ExperimentService.test.ts +++ b/packages/backend/test/unit/services/ExperimentService.test.ts @@ -377,12 +377,14 @@ describe('ExperimentService Testing', () => { provide: getRepositoryToken(ExperimentSegmentInclusionRepository), useValue: { findOne: jest.fn().mockResolvedValue(null), + getExistingSegments: jest.fn().mockResolvedValue([]), }, }, { provide: getRepositoryToken(ExperimentSegmentExclusionRepository), useValue: { findOne: jest.fn().mockResolvedValue(null), + getExistingSegments: jest.fn().mockResolvedValue([]), }, }, { @@ -416,7 +418,9 @@ describe('ExperimentService Testing', () => { }, { provide: SegmentService, - useValue: {}, + useValue: { + deleteSegment: jest.fn().mockResolvedValue({ id: 'list-1' }), + }, }, { provide: ExperimentSchedulerService, @@ -469,6 +473,45 @@ describe('ExperimentService Testing', () => { jest.clearAllMocks(); }); + describe('deleteList', () => { + it('deletes a list attached to the requested experiment and filter mode', async () => { + const inclusionRepo = module.get( + getRepositoryToken(ExperimentSegmentInclusionRepository) + ); + const segmentService = module.get(SegmentService); + (inclusionRepo.getExistingSegments as jest.Mock).mockResolvedValue([ + { + experiment: { id: mockExperiment.id, name: mockExperiment.name }, + segment: { id: 'list-1', name: 'List 1' }, + }, + ]); + + await service.deleteList('list-1', mockExperiment.id, LIST_FILTER_MODE.INCLUSION, mockUser, logger); + + expect(segmentService.deleteSegment).toHaveBeenCalledWith('list-1', logger); + }); + + it('does not delete a list attached to a different experiment', async () => { + const inclusionRepo = module.get( + getRepositoryToken(ExperimentSegmentInclusionRepository) + ); + const segmentService = module.get(SegmentService); + (inclusionRepo.getExistingSegments as jest.Mock).mockResolvedValue([ + { + experiment: { id: 'different-experiment', name: 'Different experiment' }, + segment: { id: 'list-1', name: 'List 1' }, + }, + ]); + + await expect( + service.deleteList('list-1', mockExperiment.id, LIST_FILTER_MODE.INCLUSION, mockUser, logger) + ).rejects.toThrow( + `Segment with ID list-1 not found for experiment ${mockExperiment.id} and ${LIST_FILTER_MODE.INCLUSION}` + ); + expect(segmentService.deleteSegment).not.toHaveBeenCalled(); + }); + }); + describe('legacy list type inference', () => { it('normalizes an existing standard list type', () => { const segment = { listType: 'iNdIvIdUaL' } as Segment; diff --git a/packages/backend/test/unit/services/FeatureFlagService.test.ts b/packages/backend/test/unit/services/FeatureFlagService.test.ts index bba93aa150..b4520cce30 100644 --- a/packages/backend/test/unit/services/FeatureFlagService.test.ts +++ b/packages/backend/test/unit/services/FeatureFlagService.test.ts @@ -606,11 +606,28 @@ describe('Feature Flag Service Testing', () => { }); it('should delete an include list', async () => { - const result = await service.deleteList(mockList.segment.id, LIST_FILTER_MODE.INCLUSION, mockUser1, logger); + const result = await service.deleteList( + mockList.segment.id, + mockFlag1.id, + LIST_FILTER_MODE.INCLUSION, + mockUser1, + logger + ); expect(result).toBeTruthy(); }); + it('should not delete an include list from a different feature flag', async () => { + const inclusionRepo = module.get(getRepositoryToken(FeatureFlagSegmentInclusionRepository)) as any; + const segmentService = module.get(SegmentService); + inclusionRepo.findOne = jest.fn().mockResolvedValue(undefined); + + await expect( + service.deleteList(mockList.segment.id, mockFlag1.id, LIST_FILTER_MODE.INCLUSION, mockUser1, logger) + ).rejects.toThrow(`Segment with ID ${mockList.segment.id} not found for ${LIST_FILTER_MODE.INCLUSION}`); + expect(segmentService.deleteSegment).not.toHaveBeenCalled(); + }); + it('should find one flag for the details view', async () => { const result = await service.findOneForDetails(mockFlag1.id, logger); expect(result).toEqual(mockFlag1); diff --git a/packages/frontend/projects/upgrade/src/app/core/experiments/experiments.data.service.spec.ts b/packages/frontend/projects/upgrade/src/app/core/experiments/experiments.data.service.spec.ts index 0d175d5319..a5fbf1c173 100644 --- a/packages/frontend/projects/upgrade/src/app/core/experiments/experiments.data.service.spec.ts +++ b/packages/frontend/projects/upgrade/src/app/core/experiments/experiments.data.service.spec.ts @@ -321,4 +321,26 @@ describe('ExperimentDataService', () => { expect(mockHttpClient.get).toHaveBeenCalledWith(expectedUrl); }); }); + + describe('#deleteInclusionList', () => { + it('includes the experiment id in the delete request', () => { + const segmentId = 'segment-id'; + const expectedUrl = `${API_ENDPOINTS.addExperimentInclusionList}/${segmentId}`; + + service.deleteInclusionList(segmentId, mockExperimentId); + + expect(mockHttpClient.delete).toHaveBeenCalledWith(expectedUrl, { body: { ownerId: mockExperimentId } }); + }); + }); + + describe('#deleteExclusionList', () => { + it('includes the experiment id in the delete request', () => { + const segmentId = 'segment-id'; + const expectedUrl = `${API_ENDPOINTS.addExperimentExclusionList}/${segmentId}`; + + service.deleteExclusionList(segmentId, mockExperimentId); + + expect(mockHttpClient.delete).toHaveBeenCalledWith(expectedUrl, { body: { ownerId: mockExperimentId } }); + }); + }); }); diff --git a/packages/frontend/projects/upgrade/src/app/core/experiments/experiments.data.service.ts b/packages/frontend/projects/upgrade/src/app/core/experiments/experiments.data.service.ts index 52be0ad3b0..220fcba58a 100644 --- a/packages/frontend/projects/upgrade/src/app/core/experiments/experiments.data.service.ts +++ b/packages/frontend/projects/upgrade/src/app/core/experiments/experiments.data.service.ts @@ -136,9 +136,9 @@ export class ExperimentDataService { return this.http.put(url, list); } - deleteInclusionList(segmentId: string) { + deleteInclusionList(segmentId: string, experimentId: string) { const url = `${API_ENDPOINTS.addExperimentInclusionList}/${segmentId}`; - return this.http.delete(url); + return this.http.delete(url, { body: { ownerId: experimentId } }); } addExclusionList(list: ExperimentSegmentListRequest): Observable { @@ -151,9 +151,9 @@ export class ExperimentDataService { return this.http.put(url, list); } - deleteExclusionList(segmentId: string) { + deleteExclusionList(segmentId: string, experimentId: string) { const url = `${API_ENDPOINTS.addExperimentExclusionList}/${segmentId}`; - return this.http.delete(url); + return this.http.delete(url, { body: { ownerId: experimentId } }); } fetchContextMetaData() { diff --git a/packages/frontend/projects/upgrade/src/app/core/experiments/experiments.service.ts b/packages/frontend/projects/upgrade/src/app/core/experiments/experiments.service.ts index d1c4f559de..6bce5abff1 100644 --- a/packages/frontend/projects/upgrade/src/app/core/experiments/experiments.service.ts +++ b/packages/frontend/projects/upgrade/src/app/core/experiments/experiments.service.ts @@ -257,8 +257,8 @@ export class ExperimentService { this.store$.dispatch(experimentAction.actionUpdateExperimentInclusionList({ list })); } - deleteExperimentInclusionPrivateSegmentList(segmentId: string) { - this.store$.dispatch(experimentAction.actionDeleteExperimentInclusionList({ segmentId })); + deleteExperimentInclusionPrivateSegmentList(segmentId: string, experimentId: string) { + this.store$.dispatch(experimentAction.actionDeleteExperimentInclusionList({ segmentId, experimentId })); } addExperimentExclusionPrivateSegmentList(list: ExperimentSegmentListRequest) { @@ -269,8 +269,8 @@ export class ExperimentService { this.store$.dispatch(experimentAction.actionUpdateExperimentExclusionList({ list })); } - deleteExperimentExclusionPrivateSegmentList(segmentId: string) { - this.store$.dispatch(experimentAction.actionDeleteExperimentExclusionList({ segmentId })); + deleteExperimentExclusionPrivateSegmentList(segmentId: string, experimentId: string) { + this.store$.dispatch(experimentAction.actionDeleteExperimentExclusionList({ segmentId, experimentId })); } updateExperimentConditionWeights(experiment: ExperimentVM, weightUpdates: ConditionWeightUpdate[]): void { diff --git a/packages/frontend/projects/upgrade/src/app/core/experiments/store/experiments.actions.ts b/packages/frontend/projects/upgrade/src/app/core/experiments/store/experiments.actions.ts index c562464d9c..3d73d0801d 100644 --- a/packages/frontend/projects/upgrade/src/app/core/experiments/store/experiments.actions.ts +++ b/packages/frontend/projects/upgrade/src/app/core/experiments/store/experiments.actions.ts @@ -306,7 +306,7 @@ export const actionUpdateExperimentInclusionListFailure = createAction( export const actionDeleteExperimentInclusionList = createAction( '[Experiment] Delete Experiment Inclusion List', - props<{ segmentId: string }>() + props<{ segmentId: string; experimentId: string }>() ); export const actionDeleteExperimentInclusionListSuccess = createAction( @@ -351,7 +351,7 @@ export const actionUpdateExperimentExclusionListFailure = createAction( export const actionDeleteExperimentExclusionList = createAction( '[Experiment] Delete Experiment Exclusion List', - props<{ segmentId: string }>() + props<{ segmentId: string; experimentId: string }>() ); export const actionDeleteExperimentExclusionListSuccess = createAction( diff --git a/packages/frontend/projects/upgrade/src/app/core/experiments/store/experiments.effects.ts b/packages/frontend/projects/upgrade/src/app/core/experiments/store/experiments.effects.ts index ae3a3a20a6..3c6091d162 100644 --- a/packages/frontend/projects/upgrade/src/app/core/experiments/store/experiments.effects.ts +++ b/packages/frontend/projects/upgrade/src/app/core/experiments/store/experiments.effects.ts @@ -605,9 +605,8 @@ export class ExperimentEffects { deleteExperimentInclusionList$ = createEffect(() => this.actions$.pipe( ofType(experimentAction.actionDeleteExperimentInclusionList), - map((action) => action.segmentId), - switchMap((segmentId) => { - return this.experimentDataService.deleteInclusionList(segmentId).pipe( + switchMap(({ segmentId, experimentId }) => { + return this.experimentDataService.deleteInclusionList(segmentId, experimentId).pipe( map(() => { this.notificationService.showSuccess(this.translate.instant('experiments.inclusions.delete-success.text')); return experimentAction.actionDeleteExperimentInclusionListSuccess({ segmentId }); @@ -672,9 +671,8 @@ export class ExperimentEffects { deleteExperimentExclusionList$ = createEffect(() => this.actions$.pipe( ofType(experimentAction.actionDeleteExperimentExclusionList), - map((action) => action.segmentId), - switchMap((segmentId) => { - return this.experimentDataService.deleteExclusionList(segmentId).pipe( + switchMap(({ segmentId, experimentId }) => { + return this.experimentDataService.deleteExclusionList(segmentId, experimentId).pipe( map(() => { this.notificationService.showSuccess(this.translate.instant('experiments.exclusions.delete-success.text')); return experimentAction.actionDeleteExperimentExclusionListSuccess({ segmentId }); diff --git a/packages/frontend/projects/upgrade/src/app/core/feature-flags/feature-flags.data.service.ts b/packages/frontend/projects/upgrade/src/app/core/feature-flags/feature-flags.data.service.ts index fc751c59dd..d94c9de8ee 100644 --- a/packages/frontend/projects/upgrade/src/app/core/feature-flags/feature-flags.data.service.ts +++ b/packages/frontend/projects/upgrade/src/app/core/feature-flags/feature-flags.data.service.ts @@ -120,9 +120,9 @@ export class FeatureFlagsDataService { return this.http.put(url, list); } - deleteInclusionList(segmentId: string) { + deleteInclusionList(segmentId: string, flagId: string) { const url = `${API_ENDPOINTS.addFlagInclusionList}/${segmentId}`; - return this.http.delete(url); + return this.http.delete(url, { body: { ownerId: flagId } }); } updateInclusionListStatus(segmentId: string, enabled: boolean) { @@ -140,9 +140,9 @@ export class FeatureFlagsDataService { return this.http.put(url, list); } - deleteExclusionList(segmentId: string) { + deleteExclusionList(segmentId: string, flagId: string) { const url = `${API_ENDPOINTS.addFlagExclusionList}/${segmentId}`; - return this.http.delete(url); + return this.http.delete(url, { body: { ownerId: flagId } }); } updateExclusionListStatus(segmentId: string, enabled: boolean) { diff --git a/packages/frontend/projects/upgrade/src/app/core/feature-flags/feature-flags.service.ts b/packages/frontend/projects/upgrade/src/app/core/feature-flags/feature-flags.service.ts index 64d8324d63..c5f2cd9c50 100644 --- a/packages/frontend/projects/upgrade/src/app/core/feature-flags/feature-flags.service.ts +++ b/packages/frontend/projects/upgrade/src/app/core/feature-flags/feature-flags.service.ts @@ -183,8 +183,8 @@ export class FeatureFlagsService { this.store$.dispatch(FeatureFlagsActions.actionUpdateFeatureFlagInclusionList({ list })); } - deleteFeatureFlagInclusionPrivateSegmentList(segmentId: string) { - this.store$.dispatch(FeatureFlagsActions.actionDeleteFeatureFlagInclusionList({ segmentId })); + deleteFeatureFlagInclusionPrivateSegmentList(segmentId: string, flagId: string) { + this.store$.dispatch(FeatureFlagsActions.actionDeleteFeatureFlagInclusionList({ segmentId, flagId })); } updateFeatureFlagInclusionListStatus(segmentId: string, enabled: boolean) { @@ -199,8 +199,8 @@ export class FeatureFlagsService { this.store$.dispatch(FeatureFlagsActions.actionUpdateFeatureFlagExclusionList({ list })); } - deleteFeatureFlagExclusionPrivateSegmentList(segmentId: string) { - this.store$.dispatch(FeatureFlagsActions.actionDeleteFeatureFlagExclusionList({ segmentId })); + deleteFeatureFlagExclusionPrivateSegmentList(segmentId: string, flagId: string) { + this.store$.dispatch(FeatureFlagsActions.actionDeleteFeatureFlagExclusionList({ segmentId, flagId })); } updateFeatureFlagExclusionListStatus(segmentId: string, enabled: boolean) { diff --git a/packages/frontend/projects/upgrade/src/app/core/feature-flags/store/feature-flags.actions.ts b/packages/frontend/projects/upgrade/src/app/core/feature-flags/store/feature-flags.actions.ts index 30d846ac11..7fce953188 100644 --- a/packages/frontend/projects/upgrade/src/app/core/feature-flags/store/feature-flags.actions.ts +++ b/packages/frontend/projects/upgrade/src/app/core/feature-flags/store/feature-flags.actions.ts @@ -217,7 +217,7 @@ export const actionUpdateFeatureFlagInclusionListStatusFailure = createAction( export const actionDeleteFeatureFlagInclusionList = createAction( '[Feature Flags] Delete Feature Flag Inclusion List', - props<{ segmentId: string }>() + props<{ segmentId: string; flagId: string }>() ); export const actionDeleteFeatureFlagInclusionListSuccess = createAction( @@ -277,7 +277,7 @@ export const actionUpdateFeatureFlagExclusionListStatusFailure = createAction( export const actionDeleteFeatureFlagExclusionList = createAction( '[Feature Flags] Delete Feature Flag Exclusion List', - props<{ segmentId: string }>() + props<{ segmentId: string; flagId: string }>() ); export const actionDeleteFeatureFlagExclusionListSuccess = createAction( diff --git a/packages/frontend/projects/upgrade/src/app/core/feature-flags/store/feature-flags.effects.ts b/packages/frontend/projects/upgrade/src/app/core/feature-flags/store/feature-flags.effects.ts index 7835760fe0..70fbd57588 100644 --- a/packages/frontend/projects/upgrade/src/app/core/feature-flags/store/feature-flags.effects.ts +++ b/packages/frontend/projects/upgrade/src/app/core/feature-flags/store/feature-flags.effects.ts @@ -255,9 +255,8 @@ export class FeatureFlagsEffects { deleteFeatureFlagInclusionList$ = createEffect(() => this.actions$.pipe( ofType(FeatureFlagsActions.actionDeleteFeatureFlagInclusionList), - map((action) => action.segmentId), - switchMap((segmentId) => { - return this.featureFlagsDataService.deleteInclusionList(segmentId).pipe( + switchMap(({ segmentId, flagId }) => { + return this.featureFlagsDataService.deleteInclusionList(segmentId, flagId).pipe( map(() => { this.notificationService.showSuccess( this.translate.instant('feature-flags.inclusions.delete-success.text') @@ -326,9 +325,8 @@ export class FeatureFlagsEffects { deleteFeatureFlagExclusionList$ = createEffect(() => this.actions$.pipe( ofType(FeatureFlagsActions.actionDeleteFeatureFlagExclusionList), - map((action) => action.segmentId), - switchMap((segmentId) => { - return this.featureFlagsDataService.deleteExclusionList(segmentId).pipe( + switchMap(({ segmentId, flagId }) => { + return this.featureFlagsDataService.deleteExclusionList(segmentId, flagId).pipe( map(() => { this.notificationService.showSuccess( this.translate.instant('feature-flags.exclusions.delete-success.text') diff --git a/packages/frontend/projects/upgrade/src/app/core/segments/list-details.data.service.spec.ts b/packages/frontend/projects/upgrade/src/app/core/segments/list-details.data.service.spec.ts index 415a1be4d8..0d459399c2 100644 --- a/packages/frontend/projects/upgrade/src/app/core/segments/list-details.data.service.spec.ts +++ b/packages/frontend/projects/upgrade/src/app/core/segments/list-details.data.service.spec.ts @@ -1,4 +1,4 @@ -import { of } from 'rxjs'; +import { firstValueFrom, of } from 'rxjs'; import { EXPERIMENT_STATE, LIST_FILTER_MODE, SEGMENT_TYPE } from 'upgrade_types'; import { ExperimentDataService } from '../experiments/experiments.data.service'; import { FeatureFlagsDataService } from '../feature-flags/feature-flags.data.service'; @@ -133,6 +133,62 @@ describe('ListDetailsDataService', () => { }); }); + it('rejects an experiment list that is not attached to the requested owner and filter mode', async () => { + experimentDataService.getExperimentById.mockReturnValue( + of({ + id: 'experiment-id', + name: 'Test experiment', + state: EXPERIMENT_STATE.ENROLLING, + experimentSegmentInclusion: [], + experimentSegmentExclusion: [{ segment }], + }) + ); + + await expect( + firstValueFrom( + service.fetchListDetails(LIST_OWNER_TYPE.EXPERIMENT, 'experiment-id', LIST_FILTER_MODE.INCLUSION, segment.id) + ) + ).rejects.toThrow(`List ${segment.id} does not belong to owner experiment-id.`); + expect(segmentsDataService.fetchSegmentWithMembersById).not.toHaveBeenCalled(); + }); + + it('rejects a feature flag list that is not attached to the requested owner', async () => { + featureFlagsDataService.fetchFeatureFlagById.mockReturnValue( + of({ + id: 'flag-id', + name: 'Test flag', + featureFlagSegmentInclusion: [], + featureFlagSegmentExclusion: [], + }) + ); + + await expect( + firstValueFrom( + service.fetchOwner(LIST_OWNER_TYPE.FEATURE_FLAG, 'flag-id', LIST_FILTER_MODE.INCLUSION, segment.id) + ) + ).rejects.toThrow(`List ${segment.id} does not belong to owner flag-id.`); + }); + + it('rejects a nested list that is not attached to the requested segment', async () => { + segmentsDataService.getSegmentById.mockReturnValue( + of({ segment: { id: 'parent-id', name: 'Parent segment', subSegments: [] } }) + ); + + await expect( + firstValueFrom(service.fetchOwner(LIST_OWNER_TYPE.SEGMENT, 'parent-id', LIST_FILTER_MODE.EXCLUSION, segment.id)) + ).rejects.toThrow(`List ${segment.id} does not belong to owner parent-id.`); + }); + + it('rejects an inclusion URL for a nested segment list', async () => { + segmentsDataService.getSegmentById.mockReturnValue( + of({ segment: { id: 'parent-id', name: 'Parent segment', subSegments: [segment] } }) + ); + + await expect( + firstValueFrom(service.fetchOwner(LIST_OWNER_TYPE.SEGMENT, 'parent-id', LIST_FILTER_MODE.INCLUSION, segment.id)) + ).rejects.toThrow(`List ${segment.id} does not belong to owner parent-id for ${LIST_FILTER_MODE.INCLUSION}.`); + }); + it('uses the experiment inclusion endpoint with the existing full-list payload', (done) => { experimentDataService.updateInclusionList.mockReturnValue(of({ segment })); @@ -186,4 +242,26 @@ describe('ListDetailsDataService', () => { done(); }); }); + + it('deletes an experiment list with its owner id', (done) => { + experimentDataService.deleteInclusionList.mockReturnValue(of(undefined)); + + service + .deleteList(LIST_OWNER_TYPE.EXPERIMENT, LIST_FILTER_MODE.INCLUSION, 'experiment-id', segment.id) + .subscribe(() => { + expect(experimentDataService.deleteInclusionList).toHaveBeenCalledWith(segment.id, 'experiment-id'); + done(); + }); + }); + + it('deletes a feature flag list with its owner id', (done) => { + featureFlagsDataService.deleteExclusionList.mockReturnValue(of(undefined)); + + service + .deleteList(LIST_OWNER_TYPE.FEATURE_FLAG, LIST_FILTER_MODE.EXCLUSION, 'flag-id', segment.id) + .subscribe(() => { + expect(featureFlagsDataService.deleteExclusionList).toHaveBeenCalledWith(segment.id, 'flag-id'); + done(); + }); + }); }); diff --git a/packages/frontend/projects/upgrade/src/app/core/segments/list-details.data.service.ts b/packages/frontend/projects/upgrade/src/app/core/segments/list-details.data.service.ts index a83dab9bcb..c64a4ae6da 100644 --- a/packages/frontend/projects/upgrade/src/app/core/segments/list-details.data.service.ts +++ b/packages/frontend/projects/upgrade/src/app/core/segments/list-details.data.service.ts @@ -1,5 +1,5 @@ import { Injectable } from '@angular/core'; -import { Observable, map } from 'rxjs'; +import { Observable, map, switchMap } from 'rxjs'; import { EXPERIMENT_STATE, LIST_FILTER_MODE } from 'upgrade_types'; import { ExperimentDataService } from '../experiments/experiments.data.service'; import { Experiment } from '../experiments/store/experiments.model'; @@ -23,8 +23,17 @@ export class ListDetailsDataService { private segmentsDataService: SegmentsDataService ) {} - fetchList(listId: string): Observable { - return this.segmentsDataService.fetchSegmentWithMembersById(listId); + fetchListDetails( + ownerType: LIST_OWNER_TYPE, + ownerId: string, + filterMode: LIST_FILTER_MODE, + listId: string + ): Observable<{ list: Segment; owner: ListDetailsOwner }> { + return this.fetchOwner(ownerType, ownerId, filterMode, listId).pipe( + switchMap((owner) => + this.segmentsDataService.fetchSegmentWithMembersById(listId).pipe(map((list) => ({ list, owner }))) + ) + ); } fetchOwner( @@ -41,13 +50,18 @@ export class ListDetailsDataService { filterMode === LIST_FILTER_MODE.INCLUSION ? experiment.experimentSegmentInclusion : experiment.experimentSegmentExclusion; + const list = this.requireOwnedList( + lists?.find((entry) => entry.segment?.id === listId), + listId, + ownerId + ); return { id: experiment.id, name: experiment.name, type: ownerType, // The experiment response carries the inferred list type for legacy lists // whose own segment row predates the listType column. - listType: lists?.find((list) => list.segment?.id === listId)?.segment?.listType, + listType: list.segment?.listType, // The owner details page locks list changes for these states, so lock them here too. isReadOnly: [EXPERIMENT_STATE.COMPLETED, EXPERIMENT_STATE.ARCHIVED].includes(experiment.state), }; @@ -60,7 +74,11 @@ export class ListDetailsDataService { filterMode === LIST_FILTER_MODE.INCLUSION ? featureFlag.featureFlagSegmentInclusion : featureFlag.featureFlagSegmentExclusion; - const list = lists?.find((entry) => entry.segment.id === listId); + const list = this.requireOwnedList( + lists?.find((entry) => entry.segment.id === listId), + listId, + ownerId + ); return { id: featureFlag.id, name: featureFlag.name, @@ -72,13 +90,23 @@ export class ListDetailsDataService { ); case LIST_OWNER_TYPE.SEGMENT: return this.segmentsDataService.getSegmentById(ownerId).pipe( - map((response: { segment: Segment }) => ({ - id: response.segment.id, - name: response.segment.name, - type: ownerType, - segmentType: response.segment.type, - listType: response.segment.subSegments?.find((subSegment) => subSegment.id === listId)?.listType, - })) + map((response: { segment: Segment }) => { + if (filterMode !== LIST_FILTER_MODE.EXCLUSION) { + throw new Error(`List ${listId} does not belong to owner ${ownerId} for ${filterMode}.`); + } + const list = this.requireOwnedList( + response.segment.subSegments?.find((subSegment) => subSegment.id === listId), + listId, + ownerId + ); + return { + id: response.segment.id, + name: response.segment.name, + type: ownerType, + segmentType: response.segment.type, + listType: list.listType, + }; + }) ); } } @@ -124,16 +152,23 @@ export class ListDetailsDataService { deleteList(ownerType: LIST_OWNER_TYPE, filterMode: LIST_FILTER_MODE, ownerId: string, listId: string) { if (ownerType === LIST_OWNER_TYPE.EXPERIMENT) { return filterMode === LIST_FILTER_MODE.INCLUSION - ? this.experimentDataService.deleteInclusionList(listId) - : this.experimentDataService.deleteExclusionList(listId); + ? this.experimentDataService.deleteInclusionList(listId, ownerId) + : this.experimentDataService.deleteExclusionList(listId, ownerId); } if (ownerType === LIST_OWNER_TYPE.FEATURE_FLAG) { return filterMode === LIST_FILTER_MODE.INCLUSION - ? this.featureFlagsDataService.deleteInclusionList(listId) - : this.featureFlagsDataService.deleteExclusionList(listId); + ? this.featureFlagsDataService.deleteInclusionList(listId, ownerId) + : this.featureFlagsDataService.deleteExclusionList(listId, ownerId); } return this.segmentsDataService.deleteSegmentList(listId, ownerId); } + + private requireOwnedList(list: T | undefined, listId: string, ownerId: string): T { + if (!list) { + throw new Error(`List ${listId} does not belong to owner ${ownerId}.`); + } + return list; + } } diff --git a/packages/frontend/projects/upgrade/src/app/features/dashboard/experiments/pages/experiment-details-page/experiment-details-page-content/experiment-exclusions-section-card/experiment-exclusions-section-card.component.ts b/packages/frontend/projects/upgrade/src/app/features/dashboard/experiments/pages/experiment-details-page/experiment-details-page-content/experiment-exclusions-section-card/experiment-exclusions-section-card.component.ts index 94f25f5fe8..6a20fba324 100644 --- a/packages/frontend/projects/upgrade/src/app/features/dashboard/experiments/pages/experiment-details-page/experiment-details-page-content/experiment-exclusions-section-card/experiment-exclusions-section-card.component.ts +++ b/packages/frontend/projects/upgrade/src/app/features/dashboard/experiments/pages/experiment-details-page/experiment-details-page-content/experiment-exclusions-section-card/experiment-exclusions-section-card.component.ts @@ -123,7 +123,7 @@ export class ExperimentExclusionsSectionCardComponent implements OnInit { this.onEditExcludeList(event.rowData, experimentId); break; case PARTICIPANT_LIST_ROW_ACTION.DELETE: - this.onDeleteExcludeList(event.rowData.segment); + this.onDeleteExcludeList(event.rowData.segment, experimentId); break; default: console.log('Unknown row action:', event.action); @@ -134,14 +134,14 @@ export class ExperimentExclusionsSectionCardComponent implements OnInit { this.dialogService.openExperimentEditExcludeListModal(rowData, rowData.segment.context, experimentId); } - onDeleteExcludeList(segment: Segment): void { + onDeleteExcludeList(segment: Segment, experimentId: string): void { this.dialogService .openDeleteExcludeListModal(segment.name) .afterClosed() .pipe(take(1)) .subscribe((confirmClicked) => { if (confirmClicked) { - this.experimentService.deleteExperimentExclusionPrivateSegmentList(segment.id); + this.experimentService.deleteExperimentExclusionPrivateSegmentList(segment.id, experimentId); } }); } diff --git a/packages/frontend/projects/upgrade/src/app/features/dashboard/experiments/pages/experiment-details-page/experiment-details-page-content/experiment-inclusions-section-card/experiment-inclusions-section-card.component.ts b/packages/frontend/projects/upgrade/src/app/features/dashboard/experiments/pages/experiment-details-page/experiment-details-page-content/experiment-inclusions-section-card/experiment-inclusions-section-card.component.ts index 21324e074a..e2aa503bff 100644 --- a/packages/frontend/projects/upgrade/src/app/features/dashboard/experiments/pages/experiment-details-page/experiment-details-page-content/experiment-inclusions-section-card/experiment-inclusions-section-card.component.ts +++ b/packages/frontend/projects/upgrade/src/app/features/dashboard/experiments/pages/experiment-details-page/experiment-details-page-content/experiment-inclusions-section-card/experiment-inclusions-section-card.component.ts @@ -219,7 +219,7 @@ export class ExperimentInclusionsSectionCardComponent implements OnInit, OnDestr this.onEditIncludeList(event.rowData, experimentId); break; case PARTICIPANT_LIST_ROW_ACTION.DELETE: - this.onDeleteIncludeList(event.rowData.segment); + this.onDeleteIncludeList(event.rowData.segment, experimentId); break; default: console.log('Unknown action:', event.action); @@ -230,14 +230,14 @@ export class ExperimentInclusionsSectionCardComponent implements OnInit, OnDestr this.dialogService.openExperimentEditIncludeListModal(rowData, rowData.segment.context, experimentId); } - onDeleteIncludeList(segment: Segment): void { + onDeleteIncludeList(segment: Segment, experimentId: string): void { this.dialogService .openDeleteIncludeListModal(segment.name) .afterClosed() .pipe(take(1)) .subscribe((confirmClicked) => { if (confirmClicked) { - this.experimentService.deleteExperimentInclusionPrivateSegmentList(segment.id); + this.experimentService.deleteExperimentInclusionPrivateSegmentList(segment.id, experimentId); } }); } diff --git a/packages/frontend/projects/upgrade/src/app/features/dashboard/feature-flags/pages/feature-flag-details-page/feature-flag-details-page-content/feature-flag-exclusions-section-card/feature-flag-exclusions-section-card.component.ts b/packages/frontend/projects/upgrade/src/app/features/dashboard/feature-flags/pages/feature-flag-details-page/feature-flag-details-page-content/feature-flag-exclusions-section-card/feature-flag-exclusions-section-card.component.ts index 6c95f3e191..78f9e79c93 100644 --- a/packages/frontend/projects/upgrade/src/app/features/dashboard/feature-flags/pages/feature-flag-details-page/feature-flag-details-page-content/feature-flag-exclusions-section-card/feature-flag-exclusions-section-card.component.ts +++ b/packages/frontend/projects/upgrade/src/app/features/dashboard/feature-flags/pages/feature-flag-details-page/feature-flag-details-page-content/feature-flag-exclusions-section-card/feature-flag-exclusions-section-card.component.ts @@ -105,7 +105,7 @@ export class FeatureFlagExclusionsSectionCardComponent { this.onEditExcludeList(event.rowData, flagId); break; case PARTICIPANT_LIST_ROW_ACTION.DELETE: - this.onDeleteExcludeList(event.rowData.segment); + this.onDeleteExcludeList(event.rowData.segment, flagId); break; } } @@ -114,13 +114,13 @@ export class FeatureFlagExclusionsSectionCardComponent { this.dialogService.openFeatureFlagEditExcludeListModal(rowData, rowData.segment.context, flagId); } - onDeleteExcludeList(segment: Segment): void { + onDeleteExcludeList(segment: Segment, flagId: string): void { this.dialogService .openDeleteExcludeListModal(segment.name) .afterClosed() .subscribe((confirmClicked) => { if (confirmClicked) { - this.featureFlagService.deleteFeatureFlagExclusionPrivateSegmentList(segment.id); + this.featureFlagService.deleteFeatureFlagExclusionPrivateSegmentList(segment.id, flagId); } }); } diff --git a/packages/frontend/projects/upgrade/src/app/features/dashboard/feature-flags/pages/feature-flag-details-page/feature-flag-details-page-content/feature-flag-inclusions-section-card/feature-flag-inclusions-section-card.component.ts b/packages/frontend/projects/upgrade/src/app/features/dashboard/feature-flags/pages/feature-flag-details-page/feature-flag-details-page-content/feature-flag-inclusions-section-card/feature-flag-inclusions-section-card.component.ts index 369475e629..88a662c6dd 100644 --- a/packages/frontend/projects/upgrade/src/app/features/dashboard/feature-flags/pages/feature-flag-details-page/feature-flag-details-page-content/feature-flag-inclusions-section-card/feature-flag-inclusions-section-card.component.ts +++ b/packages/frontend/projects/upgrade/src/app/features/dashboard/feature-flags/pages/feature-flag-details-page/feature-flag-details-page-content/feature-flag-inclusions-section-card/feature-flag-inclusions-section-card.component.ts @@ -169,7 +169,7 @@ export class FeatureFlagInclusionsSectionCardComponent { this.onEditIncludeList(event.rowData, flagId); break; case PARTICIPANT_LIST_ROW_ACTION.DELETE: - this.onDeleteIncludeList(event.rowData.segment); + this.onDeleteIncludeList(event.rowData.segment, flagId); break; } } @@ -200,13 +200,13 @@ export class FeatureFlagInclusionsSectionCardComponent { this.dialogService.openFeatureFlagEditIncludeListModal(rowData, rowData.segment.context, flagId); } - onDeleteIncludeList(segment: Segment): void { + onDeleteIncludeList(segment: Segment, flagId: string): void { this.dialogService .openDeleteIncludeListModal(segment.name) .afterClosed() .subscribe((confirmClicked) => { if (confirmClicked) { - this.featureFlagService.deleteFeatureFlagInclusionPrivateSegmentList(segment.id); + this.featureFlagService.deleteFeatureFlagInclusionPrivateSegmentList(segment.id, flagId); } }); } diff --git a/packages/frontend/projects/upgrade/src/app/features/dashboard/segments/pages/list-details-page/list-details-page.component.ts b/packages/frontend/projects/upgrade/src/app/features/dashboard/segments/pages/list-details-page/list-details-page.component.ts index 0030e44482..71739860c8 100644 --- a/packages/frontend/projects/upgrade/src/app/features/dashboard/segments/pages/list-details-page/list-details-page.component.ts +++ b/packages/frontend/projects/upgrade/src/app/features/dashboard/segments/pages/list-details-page/list-details-page.component.ts @@ -18,7 +18,7 @@ import { } from '@shared-component-lib'; import { KeyValueFormat } from '@shared-component-lib/common-section-card-overview-details/common-section-card-overview-details.component'; import { CommonSearchWidgetSearchParams } from '@shared-component-lib/common-section-card-search-header/common-section-card-search-header.component'; -import { finalize, forkJoin, Subscription } from 'rxjs'; +import { finalize, Subscription } from 'rxjs'; import { IMenuButtonItem, LIST_FILTER_MODE, SEGMENT_TYPE } from 'upgrade_types'; import { AuthService } from '../../../../../core/auth/auth.service'; import { NotificationService } from '../../../../../core/core.module'; @@ -213,10 +213,8 @@ export class ListDetailsPageComponent implements OnInit, OnDestroy { this.isLoading = true; this.changeDetectorRef.markForCheck(); this.subscriptions.add( - forkJoin({ - list: this.listDetailsDataService.fetchList(this.listId), - owner: this.listDetailsDataService.fetchOwner(this.ownerType, this.ownerId, this.filterMode, this.listId), - }) + this.listDetailsDataService + .fetchListDetails(this.ownerType, this.ownerId, this.filterMode, this.listId) .pipe( finalize(() => { this.isLoading = false; diff --git a/postman/PlatformAPI.postman_collection.json b/postman/PlatformAPI.postman_collection.json index 26342b848f..34f3949d1f 100644 --- a/postman/PlatformAPI.postman_collection.json +++ b/postman/PlatformAPI.postman_collection.json @@ -463,6 +463,10 @@ "request": { "method": "DELETE", "header": [{ "key": "Content-Type", "value": "application/json" }], + "body": { + "mode": "raw", + "raw": "{\n \"ownerId\": \"{{flagId}}\"\n}" + }, "url": { "raw": "{{baseUrl}}/flags/inclusionList/segment-001", "host": ["{{baseUrl}}"], @@ -476,6 +480,10 @@ "request": { "method": "DELETE", "header": [{ "key": "Content-Type", "value": "application/json" }], + "body": { + "mode": "raw", + "raw": "{\n \"ownerId\": \"{{flagId}}\"\n}" + }, "url": { "raw": "{{baseUrl}}/flags/exclusionList/segment-002", "host": ["{{baseUrl}}"], From 157fe782cbcd4a4c7969eb1ee604565163867a83 Mon Sep 17 00:00:00 2001 From: Zack Lee Date: Fri, 21 Aug 2026 13:59:51 -0400 Subject: [PATCH 06/33] fix: prevent formula injection in list CSV exports --- .../common-export-helpers.service.spec.ts | 17 +++++++++++++++++ .../services/common-export-helpers.service.ts | 13 ++++++++++++- 2 files changed, 29 insertions(+), 1 deletion(-) create mode 100644 packages/frontend/projects/upgrade/src/app/shared/services/common-export-helpers.service.spec.ts diff --git a/packages/frontend/projects/upgrade/src/app/shared/services/common-export-helpers.service.spec.ts b/packages/frontend/projects/upgrade/src/app/shared/services/common-export-helpers.service.spec.ts new file mode 100644 index 0000000000..edc87c36c5 --- /dev/null +++ b/packages/frontend/projects/upgrade/src/app/shared/services/common-export-helpers.service.spec.ts @@ -0,0 +1,17 @@ +import { serializeValuesAsCSV } from './common-export-helpers.service'; + +describe('CommonExportHelpersService', () => { + describe('serializeValuesAsCSV', () => { + it('neutralizes values that spreadsheet applications could interpret as formulas', () => { + expect( + serializeValuesAsCSV(['plain', '=SUM(A1:A2)', '+cmd', '-1+2', '@SUM(A1:A2)', '\tformula', '\rformula']) + ).toBe(['plain', "'=SUM(A1:A2)", "'+cmd", "'-1+2", "'@SUM(A1:A2)", "'\tformula", '"\'\rformula"'].join('\n')); + }); + + it('escapes values that contain CSV control characters', () => { + expect(serializeValuesAsCSV(['plain', 'one,two', 'say "hello"', 'line\nbreak'])).toBe( + ['plain', '"one,two"', '"say ""hello"""', '"line\nbreak"'].join('\n') + ); + }); + }); +}); diff --git a/packages/frontend/projects/upgrade/src/app/shared/services/common-export-helpers.service.ts b/packages/frontend/projects/upgrade/src/app/shared/services/common-export-helpers.service.ts index cb6caa67e0..18b88e4dff 100644 --- a/packages/frontend/projects/upgrade/src/app/shared/services/common-export-helpers.service.ts +++ b/packages/frontend/projects/upgrade/src/app/shared/services/common-export-helpers.service.ts @@ -1,6 +1,17 @@ import { Injectable } from '@angular/core'; import JSZip from 'jszip'; +const SPREADSHEET_FORMULA_PREFIX = /^[=+\-@\t\r]/; + +function escapeCSVField(value: string): string { + const safeValue = SPREADSHEET_FORMULA_PREFIX.test(value) ? `'${value}` : value; + return /[",\r\n]/.test(safeValue) ? `"${safeValue.replace(/"/g, '""')}"` : safeValue; +} + +export function serializeValuesAsCSV(values: string[]): string { + return values.map(escapeCSVField).join('\n'); +} + @Injectable({ providedIn: 'root', }) @@ -34,7 +45,7 @@ export class CommonExportHelpersService { } downloadValuesAsCSV(values: string[], fileName: string): void { - const csvContent = values.join('\n'); + const csvContent = serializeValuesAsCSV(values); const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' }); const link = document.createElement('a'); if (link.download !== undefined) { From 678d06ccfa7426164444386708f0398b6198f9f7 Mon Sep 17 00:00:00 2001 From: Zack Lee Date: Fri, 21 Aug 2026 15:20:29 -0400 Subject: [PATCH 07/33] fix: remove client-only list value limit --- .../core/segments/list-values.utils.spec.ts | 20 ------------------- .../app/core/segments/list-values.utils.ts | 6 ------ .../upsert-list-values-modal.component.html | 2 -- .../upsert-list-values-modal.component.scss | 5 ----- .../upsert-list-values-modal.component.ts | 10 +--------- .../list-details-page.component.ts | 11 +++------- 6 files changed, 4 insertions(+), 50 deletions(-) diff --git a/packages/frontend/projects/upgrade/src/app/core/segments/list-values.utils.spec.ts b/packages/frontend/projects/upgrade/src/app/core/segments/list-values.utils.spec.ts index 567d1ee7c7..8b7894c3ad 100644 --- a/packages/frontend/projects/upgrade/src/app/core/segments/list-values.utils.spec.ts +++ b/packages/frontend/projects/upgrade/src/app/core/segments/list-values.utils.spec.ts @@ -1,7 +1,5 @@ import { - MAX_LIST_VALUES, containsListValueSeparator, - exceedsListValueLimit, mergeUniqueListValues, parseSingleColumnCSV, splitListValues, @@ -35,24 +33,6 @@ describe('list values utilities', () => { duplicateValues: ['two', 'three'], }); }); - - it('handles the 3,000-value WIP target', () => { - const values = Array.from({ length: MAX_LIST_VALUES }, (_, index) => `value-${index}`); - - expect(mergeUniqueListValues([], values).values).toHaveLength(MAX_LIST_VALUES); - }); - }); - - describe('exceedsListValueLimit', () => { - const existingValues = Array.from({ length: MAX_LIST_VALUES }, (_, index) => `value-${index}`); - - it('allows duplicate input when the list is already at the limit', () => { - expect(exceedsListValueLimit(existingValues, ['value-0'])).toBe(false); - }); - - it('blocks a new value when the list is already at the limit', () => { - expect(exceedsListValueLimit(existingValues, ['new-value'])).toBe(true); - }); }); describe('parseSingleColumnCSV', () => { diff --git a/packages/frontend/projects/upgrade/src/app/core/segments/list-values.utils.ts b/packages/frontend/projects/upgrade/src/app/core/segments/list-values.utils.ts index fa7107a2c8..b688921f93 100644 --- a/packages/frontend/projects/upgrade/src/app/core/segments/list-values.utils.ts +++ b/packages/frontend/projects/upgrade/src/app/core/segments/list-values.utils.ts @@ -4,8 +4,6 @@ export interface MergeListValuesResult { duplicateValues: string[]; } -export const MAX_LIST_VALUES = 3000; - const VALUE_SEPARATORS = /[,\t\r\n]+/; export function splitListValues(rawValue: string): string[] { @@ -47,10 +45,6 @@ export function mergeUniqueListValues(existingValues: string[], incomingValues: }; } -export function exceedsListValueLimit(existingValues: string[], incomingValues: string[]): boolean { - return mergeUniqueListValues(existingValues, incomingValues).values.length > MAX_LIST_VALUES; -} - export function parseSingleColumnCSV(content: string): string[] { const values = content .split(/\r?\n/) diff --git a/packages/frontend/projects/upgrade/src/app/features/dashboard/segments/modals/upsert-list-values-modal/upsert-list-values-modal.component.html b/packages/frontend/projects/upgrade/src/app/features/dashboard/segments/modals/upsert-list-values-modal/upsert-list-values-modal.component.html index 76e9c5288b..d90ed2342b 100644 --- a/packages/frontend/projects/upgrade/src/app/features/dashboard/segments/modals/upsert-list-values-modal/upsert-list-values-modal.component.html +++ b/packages/frontend/projects/upgrade/src/app/features/dashboard/segments/modals/upsert-list-values-modal/upsert-list-values-modal.component.html @@ -53,8 +53,6 @@ >

Separate values with commas or new lines.

- } @if (exceedsValueLimit) { - A list can contain up to 3,000 values. }
diff --git a/packages/frontend/projects/upgrade/src/app/features/dashboard/segments/modals/upsert-list-values-modal/upsert-list-values-modal.component.scss b/packages/frontend/projects/upgrade/src/app/features/dashboard/segments/modals/upsert-list-values-modal/upsert-list-values-modal.component.scss index 5b04a6328b..b6c2a00c30 100644 --- a/packages/frontend/projects/upgrade/src/app/features/dashboard/segments/modals/upsert-list-values-modal/upsert-list-values-modal.component.scss +++ b/packages/frontend/projects/upgrade/src/app/features/dashboard/segments/modals/upsert-list-values-modal/upsert-list-values-modal.component.scss @@ -37,11 +37,6 @@ } } -.error-message { - display: block; - color: var(--red); -} - .file-summary { display: flex; align-items: center; diff --git a/packages/frontend/projects/upgrade/src/app/features/dashboard/segments/modals/upsert-list-values-modal/upsert-list-values-modal.component.ts b/packages/frontend/projects/upgrade/src/app/features/dashboard/segments/modals/upsert-list-values-modal/upsert-list-values-modal.component.ts index eb9b3459c1..bebcc8c6d3 100644 --- a/packages/frontend/projects/upgrade/src/app/features/dashboard/segments/modals/upsert-list-values-modal/upsert-list-values-modal.component.ts +++ b/packages/frontend/projects/upgrade/src/app/features/dashboard/segments/modals/upsert-list-values-modal/upsert-list-values-modal.component.ts @@ -11,7 +11,6 @@ import { CommonLearnMoreLinkComponent, CommonModalComponent } from '@shared-comp import { CommonImportContainerComponent } from '@shared-component-lib/common-import-container/common-import-container.component'; import { FILE_TYPE } from 'upgrade_types'; import { - exceedsListValueLimit, mergeUniqueListValues, parseSingleColumnCSV, splitListValues, @@ -24,7 +23,6 @@ export enum LIST_VALUES_UPDATE_MODE { export interface UpsertListValuesModalData { importOnly?: boolean; - existingValues?: string[]; } export interface UpsertListValuesModalResult { @@ -79,14 +77,8 @@ export class UpsertListValuesModalComponent { return this.data.importOnly ? 'Import' : 'Add'; } - get exceedsValueLimit(): boolean { - const existingValues = - this.data.importOnly && this.updateMode === LIST_VALUES_UPDATE_MODE.REPLACE ? [] : this.data.existingValues ?? []; - return exceedsListValueLimit(existingValues, this.values); - } - get isPrimaryActionDisabled(): boolean { - return this.values.length === 0 || this.exceedsValueLimit || !!this.errorMessage; + return this.values.length === 0 || !!this.errorMessage; } onFilesSelected(files: File[]): void { diff --git a/packages/frontend/projects/upgrade/src/app/features/dashboard/segments/pages/list-details-page/list-details-page.component.ts b/packages/frontend/projects/upgrade/src/app/features/dashboard/segments/pages/list-details-page/list-details-page.component.ts index 71739860c8..5c1fc28bdc 100644 --- a/packages/frontend/projects/upgrade/src/app/features/dashboard/segments/pages/list-details-page/list-details-page.component.ts +++ b/packages/frontend/projects/upgrade/src/app/features/dashboard/segments/pages/list-details-page/list-details-page.component.ts @@ -38,7 +38,7 @@ import { ModalSize, SimpleConfirmationModalParams, } from '@shared-component-lib/common-modal/common-modal.types'; -import { MAX_LIST_VALUES, mergeUniqueListValues } from '../../../../../core/segments/list-values.utils'; +import { mergeUniqueListValues } from '../../../../../core/segments/list-values.utils'; import { LIST_VALUES_UPDATE_MODE, UpsertListValuesModalComponent, @@ -247,7 +247,7 @@ export class ListDetailsPageComponent implements OnInit, OnDestroy { openAddValuesModal(): void { const dialogRef = this.dialog.open(UpsertListValuesModalComponent, { - data: { importOnly: false, existingValues: this.values }, + data: { importOnly: false }, width: ModalSize.STANDARD, autoFocus: 'textarea', disableClose: true, @@ -257,7 +257,7 @@ export class ListDetailsPageComponent implements OnInit, OnDestroy { openImportValuesModal(): void { const dialogRef = this.dialog.open(UpsertListValuesModalComponent, { - data: { importOnly: true, existingValues: this.values }, + data: { importOnly: true }, width: ModalSize.STANDARD, autoFocus: '.choose-file-btn', disableClose: true, @@ -482,11 +482,6 @@ export class ListDetailsPageComponent implements OnInit, OnDestroy { ? mergeUniqueListValues([], result.values) : mergeUniqueListValues(this.values, result.values); - if (mergeResult.values.length > MAX_LIST_VALUES) { - this.notificationService.showError(`A list can contain up to ${MAX_LIST_VALUES.toLocaleString()} values.`); - return; - } - if (!mergeResult.addedValues.length && result.mode === LIST_VALUES_UPDATE_MODE.APPEND) { this.notificationService.showInfo(this.getAddedValuesMessage(0, mergeResult.duplicateValues.length)); return; From b86b9e1548a6526bb1ff7c5c51716da57d04bb1a Mon Sep 17 00:00:00 2001 From: Zack Lee Date: Fri, 21 Aug 2026 15:41:48 -0400 Subject: [PATCH 08/33] fix: preserve quoted values in CSV round trips --- .../core/segments/list-values.utils.spec.ts | 22 ++++++ .../app/core/segments/list-values.utils.ts | 75 +++++++++++++++++-- 2 files changed, 89 insertions(+), 8 deletions(-) diff --git a/packages/frontend/projects/upgrade/src/app/core/segments/list-values.utils.spec.ts b/packages/frontend/projects/upgrade/src/app/core/segments/list-values.utils.spec.ts index 8b7894c3ad..d959d11054 100644 --- a/packages/frontend/projects/upgrade/src/app/core/segments/list-values.utils.spec.ts +++ b/packages/frontend/projects/upgrade/src/app/core/segments/list-values.utils.spec.ts @@ -4,6 +4,7 @@ import { parseSingleColumnCSV, splitListValues, } from './list-values.utils'; +import { serializeValuesAsCSV } from '../../shared/services/common-export-helpers.service'; describe('list values utilities', () => { describe('splitListValues', () => { @@ -40,9 +41,30 @@ describe('list values utilities', () => { expect(parseSingleColumnCSV('one\ntwo\r\nthree')).toEqual(['one', 'two', 'three']); }); + it('parses quoted values, escaped quotes, commas, and embedded line breaks', () => { + expect(parseSingleColumnCSV('one\n"say ""hello"""\n"school,one"\n"line\r\nbreak"')).toEqual([ + 'one', + 'say "hello"', + 'school,one', + 'line\r\nbreak', + ]); + }); + + it('round-trips CSV-quoted values produced by the exporter', () => { + const values = ['plain', 'say "hello"', 'school,one', 'line\nbreak']; + + expect(parseSingleColumnCSV(serializeValuesAsCSV(values))).toEqual(values); + }); + it('rejects empty and multi-column CSV files', () => { expect(() => parseSingleColumnCSV('')).toThrow('CSV file is empty'); expect(() => parseSingleColumnCSV('one,two')).toThrow('CSV should contain only one column'); + expect(() => parseSingleColumnCSV('"one",two')).toThrow('CSV should contain only one column'); + }); + + it('rejects malformed quoted values', () => { + expect(() => parseSingleColumnCSV('"unterminated')).toThrow('CSV contains malformed quoting'); + expect(() => parseSingleColumnCSV('"one"two')).toThrow('CSV contains malformed quoting'); }); }); }); diff --git a/packages/frontend/projects/upgrade/src/app/core/segments/list-values.utils.ts b/packages/frontend/projects/upgrade/src/app/core/segments/list-values.utils.ts index b688921f93..727dbf2435 100644 --- a/packages/frontend/projects/upgrade/src/app/core/segments/list-values.utils.ts +++ b/packages/frontend/projects/upgrade/src/app/core/segments/list-values.utils.ts @@ -45,18 +45,77 @@ export function mergeUniqueListValues(existingValues: string[], incomingValues: }; } +/** Parses an RFC-style CSV while rejecting records that contain more than one field. */ export function parseSingleColumnCSV(content: string): string[] { - const values = content - .split(/\r?\n/) - .map((value) => value.trim()) - .filter(Boolean); + const values: string[] = []; + let value = ''; + let isQuoted = false; + let hasClosedQuote = false; - if (!values.length) { - throw new Error('CSV file is empty'); + const addValue = (): void => { + const normalizedValue = value.trim(); + if (normalizedValue) { + values.push(normalizedValue); + } + value = ''; + hasClosedQuote = false; + }; + + for (let index = 0; index < content.length; index++) { + const character = content[index]; + + if (isQuoted) { + if (character === '"') { + if (content[index + 1] === '"') { + value += '"'; + index++; + } else { + isQuoted = false; + hasClosedQuote = true; + } + } else { + value += character; + } + continue; + } + + if (hasClosedQuote) { + if (character === ' ' || character === '\t') { + continue; + } + if (character === ',') { + throw new Error('CSV should contain only one column'); + } + if (character !== '\r' && character !== '\n') { + throw new Error('CSV contains malformed quoting'); + } + } else if (character === '"' && !value.trim()) { + value = ''; + isQuoted = true; + continue; + } else if (character === ',') { + throw new Error('CSV should contain only one column'); + } else if (character !== '\r' && character !== '\n') { + value += character; + continue; + } + + addValue(); + if (character === '\r' && content[index + 1] === '\n') { + index++; + } + } + + if (isQuoted) { + throw new Error('CSV contains malformed quoting'); + } + + if (value || hasClosedQuote) { + addValue(); } - if (values.some((value) => value.includes(','))) { - throw new Error('CSV should contain only one column'); + if (!values.length) { + throw new Error('CSV file is empty'); } return values; From 6ddd670ff80751ff44a02e7e9e29183926fe65d9 Mon Sep 17 00:00:00 2001 From: Zack Lee Date: Fri, 21 Aug 2026 15:58:48 -0400 Subject: [PATCH 09/33] fix: make CSV formula escaping reversible --- .../core/segments/list-values.utils.spec.ts | 28 ++++++++++++++-- .../app/core/segments/list-values.utils.ts | 4 ++- .../common-export-helpers.service.spec.ts | 33 ++++++++++++++++--- .../services/common-export-helpers.service.ts | 8 +++-- 4 files changed, 63 insertions(+), 10 deletions(-) diff --git a/packages/frontend/projects/upgrade/src/app/core/segments/list-values.utils.spec.ts b/packages/frontend/projects/upgrade/src/app/core/segments/list-values.utils.spec.ts index d959d11054..83a5356acb 100644 --- a/packages/frontend/projects/upgrade/src/app/core/segments/list-values.utils.spec.ts +++ b/packages/frontend/projects/upgrade/src/app/core/segments/list-values.utils.spec.ts @@ -50,12 +50,36 @@ describe('list values utilities', () => { ]); }); - it('round-trips CSV-quoted values produced by the exporter', () => { - const values = ['plain', 'say "hello"', 'school,one', 'line\nbreak']; + it('round-trips values produced by the CSV exporter', () => { + const values = [ + 'plain', + 'say "hello"', + 'school,one', + 'line\nbreak', + '=SUM(A1:A2)', + '+cmd', + '-1+2', + '@SUM(A1:A2)', + '\tformula', + '\rformula', + '\nformula', + '=SUM(A1:A2)', + "'=SUM(A1:A2)", + "''=SUM(A1:A2)", + "'school", + ]; expect(parseSingleColumnCSV(serializeValuesAsCSV(values))).toEqual(values); }); + it('decodes formula escapes without removing genuine leading apostrophes', () => { + expect(parseSingleColumnCSV("'=SUM(A1:A2)\n''=SUM(A1:A2)\n'school")).toEqual([ + '=SUM(A1:A2)', + "'=SUM(A1:A2)", + "'school", + ]); + }); + it('rejects empty and multi-column CSV files', () => { expect(() => parseSingleColumnCSV('')).toThrow('CSV file is empty'); expect(() => parseSingleColumnCSV('one,two')).toThrow('CSV should contain only one column'); diff --git a/packages/frontend/projects/upgrade/src/app/core/segments/list-values.utils.ts b/packages/frontend/projects/upgrade/src/app/core/segments/list-values.utils.ts index 727dbf2435..1766f0db5f 100644 --- a/packages/frontend/projects/upgrade/src/app/core/segments/list-values.utils.ts +++ b/packages/frontend/projects/upgrade/src/app/core/segments/list-values.utils.ts @@ -5,6 +5,8 @@ export interface MergeListValuesResult { } const VALUE_SEPARATORS = /[,\t\r\n]+/; +// The exporter adds one apostrophe before formula-like values, including values that already begin with apostrophes. +const ESCAPED_SPREADSHEET_FORMULA_PREFIX = /^'(?='*[=+\-@\t\r\n=+-@])/; export function splitListValues(rawValue: string): string[] { return rawValue @@ -55,7 +57,7 @@ export function parseSingleColumnCSV(content: string): string[] { const addValue = (): void => { const normalizedValue = value.trim(); if (normalizedValue) { - values.push(normalizedValue); + values.push(normalizedValue.replace(ESCAPED_SPREADSHEET_FORMULA_PREFIX, '')); } value = ''; hasClosedQuote = false; diff --git a/packages/frontend/projects/upgrade/src/app/shared/services/common-export-helpers.service.spec.ts b/packages/frontend/projects/upgrade/src/app/shared/services/common-export-helpers.service.spec.ts index edc87c36c5..e251f5d543 100644 --- a/packages/frontend/projects/upgrade/src/app/shared/services/common-export-helpers.service.spec.ts +++ b/packages/frontend/projects/upgrade/src/app/shared/services/common-export-helpers.service.spec.ts @@ -4,13 +4,38 @@ describe('CommonExportHelpersService', () => { describe('serializeValuesAsCSV', () => { it('neutralizes values that spreadsheet applications could interpret as formulas', () => { expect( - serializeValuesAsCSV(['plain', '=SUM(A1:A2)', '+cmd', '-1+2', '@SUM(A1:A2)', '\tformula', '\rformula']) - ).toBe(['plain', "'=SUM(A1:A2)", "'+cmd", "'-1+2", "'@SUM(A1:A2)", "'\tformula", '"\'\rformula"'].join('\n')); + serializeValuesAsCSV([ + 'plain', + '=SUM(A1:A2)', + '+cmd', + '-1+2', + '@SUM(A1:A2)', + '\tformula', + '\rformula', + '\nformula', + '=SUM(A1:A2)', + ]) + ).toBe( + [ + '"plain"', + '"\'=SUM(A1:A2)"', + '"\'+cmd"', + '"\'-1+2"', + '"\'@SUM(A1:A2)"', + '"\'\tformula"', + '"\'\rformula"', + '"\'\nformula"', + '"\'=SUM(A1:A2)"', + ].join('\r\n') + ); }); - it('escapes values that contain CSV control characters', () => { + it('escapes CSV control characters and preserves genuine leading apostrophes', () => { expect(serializeValuesAsCSV(['plain', 'one,two', 'say "hello"', 'line\nbreak'])).toBe( - ['plain', '"one,two"', '"say ""hello"""', '"line\nbreak"'].join('\n') + ['"plain"', '"one,two"', '"say ""hello"""', '"line\nbreak"'].join('\r\n') + ); + expect(serializeValuesAsCSV(["'=SUM(A1:A2)", "''=SUM(A1:A2)", "'school"])).toBe( + ['"\'\'=SUM(A1:A2)"', "\"'''=SUM(A1:A2)\"", '"\'school"'].join('\r\n') ); }); }); diff --git a/packages/frontend/projects/upgrade/src/app/shared/services/common-export-helpers.service.ts b/packages/frontend/projects/upgrade/src/app/shared/services/common-export-helpers.service.ts index 18b88e4dff..3b6cd69f57 100644 --- a/packages/frontend/projects/upgrade/src/app/shared/services/common-export-helpers.service.ts +++ b/packages/frontend/projects/upgrade/src/app/shared/services/common-export-helpers.service.ts @@ -1,15 +1,17 @@ import { Injectable } from '@angular/core'; import JSZip from 'jszip'; -const SPREADSHEET_FORMULA_PREFIX = /^[=+\-@\t\r]/; +// Prefixing an apostrophe is the spreadsheet convention for treating formula-like cells as text. +// Match existing apostrophes too so adding one remains reversible when the CSV is imported again. +const SPREADSHEET_FORMULA_PREFIX = /^'*[=+\-@\t\r\n=+-@]/; function escapeCSVField(value: string): string { const safeValue = SPREADSHEET_FORMULA_PREFIX.test(value) ? `'${value}` : value; - return /[",\r\n]/.test(safeValue) ? `"${safeValue.replace(/"/g, '""')}"` : safeValue; + return `"${safeValue.replace(/"/g, '""')}"`; } export function serializeValuesAsCSV(values: string[]): string { - return values.map(escapeCSVField).join('\n'); + return values.map(escapeCSVField).join('\r\n'); } @Injectable({ From 8b84666279b2fd639b76b0f7aea9d9238d3c093d Mon Sep 17 00:00:00 2001 From: Zack Lee Date: Fri, 21 Aug 2026 16:24:22 -0400 Subject: [PATCH 10/33] fix: preserve CSV duplicates for result reporting --- .../app/core/segments/list-values.utils.spec.ts | 12 ++++++++++++ .../upsert-list-values-modal.component.html | 5 ----- .../upsert-list-values-modal.component.scss | 13 ------------- .../upsert-list-values-modal.component.ts | 16 ++-------------- 4 files changed, 14 insertions(+), 32 deletions(-) diff --git a/packages/frontend/projects/upgrade/src/app/core/segments/list-values.utils.spec.ts b/packages/frontend/projects/upgrade/src/app/core/segments/list-values.utils.spec.ts index 83a5356acb..9aab5fa859 100644 --- a/packages/frontend/projects/upgrade/src/app/core/segments/list-values.utils.spec.ts +++ b/packages/frontend/projects/upgrade/src/app/core/segments/list-values.utils.spec.ts @@ -41,6 +41,18 @@ describe('list values utilities', () => { expect(parseSingleColumnCSV('one\ntwo\r\nthree')).toEqual(['one', 'two', 'three']); }); + it('preserves duplicate rows for post-operation reporting', () => { + expect(parseSingleColumnCSV('one\none\ntwo')).toEqual(['one', 'one', 'two']); + }); + + it('reports both CSV and existing-list duplicates when parsed rows are merged', () => { + expect(mergeUniqueListValues(['one'], parseSingleColumnCSV('one\none\ntwo'))).toEqual({ + values: ['one', 'two'], + addedValues: ['two'], + duplicateValues: ['one', 'one'], + }); + }); + it('parses quoted values, escaped quotes, commas, and embedded line breaks', () => { expect(parseSingleColumnCSV('one\n"say ""hello"""\n"school,one"\n"line\r\nbreak"')).toEqual([ 'one', diff --git a/packages/frontend/projects/upgrade/src/app/features/dashboard/segments/modals/upsert-list-values-modal/upsert-list-values-modal.component.html b/packages/frontend/projects/upgrade/src/app/features/dashboard/segments/modals/upsert-list-values-modal/upsert-list-values-modal.component.html index d90ed2342b..a0b1ad570b 100644 --- a/packages/frontend/projects/upgrade/src/app/features/dashboard/segments/modals/upsert-list-values-modal/upsert-list-values-modal.component.html +++ b/packages/frontend/projects/upgrade/src/app/features/dashboard/segments/modals/upsert-list-values-modal/upsert-list-values-modal.component.html @@ -28,11 +28,6 @@ ×
- } @if (importDuplicateCount) { -
- info_outline - {{ importDuplicateCount }} {{ importDuplicateCount === 1 ? 'duplicate was' : 'duplicates were' }} skipped. -
}
diff --git a/packages/frontend/projects/upgrade/src/app/features/dashboard/segments/modals/upsert-list-values-modal/upsert-list-values-modal.component.scss b/packages/frontend/projects/upgrade/src/app/features/dashboard/segments/modals/upsert-list-values-modal/upsert-list-values-modal.component.scss index b6c2a00c30..9f44de2011 100644 --- a/packages/frontend/projects/upgrade/src/app/features/dashboard/segments/modals/upsert-list-values-modal/upsert-list-values-modal.component.scss +++ b/packages/frontend/projects/upgrade/src/app/features/dashboard/segments/modals/upsert-list-values-modal/upsert-list-values-modal.component.scss @@ -24,19 +24,6 @@ } } -.duplicate-message { - display: flex; - align-items: center; - gap: 6px; - color: var(--dark-grey); - - mat-icon { - width: 18px; - height: 18px; - font-size: 18px; - } -} - .file-summary { display: flex; align-items: center; diff --git a/packages/frontend/projects/upgrade/src/app/features/dashboard/segments/modals/upsert-list-values-modal/upsert-list-values-modal.component.ts b/packages/frontend/projects/upgrade/src/app/features/dashboard/segments/modals/upsert-list-values-modal/upsert-list-values-modal.component.ts index bebcc8c6d3..3bf632f095 100644 --- a/packages/frontend/projects/upgrade/src/app/features/dashboard/segments/modals/upsert-list-values-modal/upsert-list-values-modal.component.ts +++ b/packages/frontend/projects/upgrade/src/app/features/dashboard/segments/modals/upsert-list-values-modal/upsert-list-values-modal.component.ts @@ -5,16 +5,11 @@ import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; import { MatFormFieldModule } from '@angular/material/form-field'; import { MatInputModule } from '@angular/material/input'; import { MatRadioModule } from '@angular/material/radio'; -import { MatIconModule } from '@angular/material/icon'; import { TranslateModule } from '@ngx-translate/core'; import { CommonLearnMoreLinkComponent, CommonModalComponent } from '@shared-component-lib'; import { CommonImportContainerComponent } from '@shared-component-lib/common-import-container/common-import-container.component'; import { FILE_TYPE } from 'upgrade_types'; -import { - mergeUniqueListValues, - parseSingleColumnCSV, - splitListValues, -} from '../../../../../core/segments/list-values.utils'; +import { parseSingleColumnCSV, splitListValues } from '../../../../../core/segments/list-values.utils'; export enum LIST_VALUES_UPDATE_MODE { APPEND = 'append', @@ -37,7 +32,6 @@ export interface UpsertListValuesModalResult { CommonModule, FormsModule, MatFormFieldModule, - MatIconModule, MatInputModule, MatRadioModule, TranslateModule, @@ -52,7 +46,6 @@ export interface UpsertListValuesModalResult { export class UpsertListValuesModalComponent { rawValues = ''; importedValues: string[] = []; - importDuplicateCount = 0; fileName = ''; errorMessage = ''; updateMode = LIST_VALUES_UPDATE_MODE.APPEND; @@ -85,7 +78,6 @@ export class UpsertListValuesModalComponent { const file = files[0]; this.errorMessage = ''; this.importedValues = []; - this.importDuplicateCount = 0; this.fileName = file?.name ?? ''; if (!file) { @@ -95,10 +87,7 @@ export class UpsertListValuesModalComponent { const reader = new FileReader(); reader.onload = () => { try { - const parsedValues = parseSingleColumnCSV(String(reader.result ?? '')); - const mergeResult = mergeUniqueListValues([], parsedValues); - this.importedValues = mergeResult.values; - this.importDuplicateCount = mergeResult.duplicateValues.length; + this.importedValues = parseSingleColumnCSV(String(reader.result ?? '')); } catch (error) { this.errorMessage = error instanceof Error ? error.message : 'Unable to read CSV file'; } @@ -114,7 +103,6 @@ export class UpsertListValuesModalComponent { clearImportedFile(): void { this.fileName = ''; this.importedValues = []; - this.importDuplicateCount = 0; this.errorMessage = ''; } From 229384bb901e585e879d6abbe1b985984becc729 Mon Sep 17 00:00:00 2001 From: Zack Lee Date: Fri, 21 Aug 2026 17:26:20 -0400 Subject: [PATCH 11/33] fix: align CSV imports with list value validation --- .../core/segments/list-values.utils.spec.ts | 26 ++++++++++--------- .../app/core/segments/list-values.utils.ts | 3 +++ 2 files changed, 17 insertions(+), 12 deletions(-) diff --git a/packages/frontend/projects/upgrade/src/app/core/segments/list-values.utils.spec.ts b/packages/frontend/projects/upgrade/src/app/core/segments/list-values.utils.spec.ts index 9aab5fa859..24a093d163 100644 --- a/packages/frontend/projects/upgrade/src/app/core/segments/list-values.utils.spec.ts +++ b/packages/frontend/projects/upgrade/src/app/core/segments/list-values.utils.spec.ts @@ -53,28 +53,30 @@ describe('list values utilities', () => { }); }); - it('parses quoted values, escaped quotes, commas, and embedded line breaks', () => { - expect(parseSingleColumnCSV('one\n"say ""hello"""\n"school,one"\n"line\r\nbreak"')).toEqual([ - 'one', - 'say "hello"', - 'school,one', - 'line\r\nbreak', - ]); + it('parses quoted values and escaped quotes', () => { + expect(parseSingleColumnCSV('one\n"say ""hello"""')).toEqual(['one', 'say "hello"']); + }); + + it('rejects separators inside quoted values', () => { + expect(() => parseSingleColumnCSV('"school,one"')).toThrow( + 'CSV values cannot contain commas, tabs, or line breaks' + ); + expect(() => parseSingleColumnCSV('"school\tone"')).toThrow( + 'CSV values cannot contain commas, tabs, or line breaks' + ); + expect(() => parseSingleColumnCSV('"school\r\none"')).toThrow( + 'CSV values cannot contain commas, tabs, or line breaks' + ); }); it('round-trips values produced by the CSV exporter', () => { const values = [ 'plain', 'say "hello"', - 'school,one', - 'line\nbreak', '=SUM(A1:A2)', '+cmd', '-1+2', '@SUM(A1:A2)', - '\tformula', - '\rformula', - '\nformula', '=SUM(A1:A2)', "'=SUM(A1:A2)", "''=SUM(A1:A2)", diff --git a/packages/frontend/projects/upgrade/src/app/core/segments/list-values.utils.ts b/packages/frontend/projects/upgrade/src/app/core/segments/list-values.utils.ts index 1766f0db5f..c420510688 100644 --- a/packages/frontend/projects/upgrade/src/app/core/segments/list-values.utils.ts +++ b/packages/frontend/projects/upgrade/src/app/core/segments/list-values.utils.ts @@ -57,6 +57,9 @@ export function parseSingleColumnCSV(content: string): string[] { const addValue = (): void => { const normalizedValue = value.trim(); if (normalizedValue) { + if (containsListValueSeparator(normalizedValue)) { + throw new Error('CSV values cannot contain commas, tabs, or line breaks'); + } values.push(normalizedValue.replace(ESCAPED_SPREADSHEET_FORMULA_PREFIX, '')); } value = ''; From 8cb7777babf1b45f564e21717d6e04d9c8b95c15 Mon Sep 17 00:00:00 2001 From: Zack Lee Date: Fri, 21 Aug 2026 17:46:33 -0400 Subject: [PATCH 12/33] fix: preserve list values in CSV round trips --- .../core/segments/list-values.utils.spec.ts | 42 ++++++++++++++++--- .../app/core/segments/list-values.utils.ts | 4 +- .../common-export-helpers.service.spec.ts | 42 ------------------- .../services/common-export-helpers.service.ts | 7 +--- 4 files changed, 38 insertions(+), 57 deletions(-) delete mode 100644 packages/frontend/projects/upgrade/src/app/shared/services/common-export-helpers.service.spec.ts diff --git a/packages/frontend/projects/upgrade/src/app/core/segments/list-values.utils.spec.ts b/packages/frontend/projects/upgrade/src/app/core/segments/list-values.utils.spec.ts index 24a093d163..7d0a57069d 100644 --- a/packages/frontend/projects/upgrade/src/app/core/segments/list-values.utils.spec.ts +++ b/packages/frontend/projects/upgrade/src/app/core/segments/list-values.utils.spec.ts @@ -7,6 +7,38 @@ import { import { serializeValuesAsCSV } from '../../shared/services/common-export-helpers.service'; describe('list values utilities', () => { + describe('serializeValuesAsCSV', () => { + it('applies CSV quoting without changing list values', () => { + expect( + serializeValuesAsCSV([ + 'plain', + 'say "hello"', + '=SUM(A1:A2)', + '+cmd', + '-1+2', + '@SUM(A1:A2)', + '=SUM(A1:A2)', + "'=SUM(A1:A2)", + "''=SUM(A1:A2)", + "'school", + ]) + ).toBe( + [ + '"plain"', + '"say ""hello"""', + '"=SUM(A1:A2)"', + '"+cmd"', + '"-1+2"', + '"@SUM(A1:A2)"', + '"=SUM(A1:A2)"', + '"\'=SUM(A1:A2)"', + '"\'\'=SUM(A1:A2)"', + '"\'school"', + ].join('\r\n') + ); + }); + }); + describe('splitListValues', () => { it('splits pasted values on commas, tabs, and new lines', () => { expect(splitListValues('one, two\tthree\nfour\r\nfive')).toEqual(['one', 'two', 'three', 'four', 'five']); @@ -86,12 +118,10 @@ describe('list values utilities', () => { expect(parseSingleColumnCSV(serializeValuesAsCSV(values))).toEqual(values); }); - it('decodes formula escapes without removing genuine leading apostrophes', () => { - expect(parseSingleColumnCSV("'=SUM(A1:A2)\n''=SUM(A1:A2)\n'school")).toEqual([ - '=SUM(A1:A2)', - "'=SUM(A1:A2)", - "'school", - ]); + it('preserves formula-like prefixes and leading apostrophes', () => { + expect( + parseSingleColumnCSV("=SUM(A1:A2)\n+cmd\n-1+2\n@SUM(A1:A2)\n'=SUM(A1:A2)\n''=SUM(A1:A2)\n'school") + ).toEqual(['=SUM(A1:A2)', '+cmd', '-1+2', '@SUM(A1:A2)', "'=SUM(A1:A2)", "''=SUM(A1:A2)", "'school"]); }); it('rejects empty and multi-column CSV files', () => { diff --git a/packages/frontend/projects/upgrade/src/app/core/segments/list-values.utils.ts b/packages/frontend/projects/upgrade/src/app/core/segments/list-values.utils.ts index c420510688..122ea85522 100644 --- a/packages/frontend/projects/upgrade/src/app/core/segments/list-values.utils.ts +++ b/packages/frontend/projects/upgrade/src/app/core/segments/list-values.utils.ts @@ -5,8 +5,6 @@ export interface MergeListValuesResult { } const VALUE_SEPARATORS = /[,\t\r\n]+/; -// The exporter adds one apostrophe before formula-like values, including values that already begin with apostrophes. -const ESCAPED_SPREADSHEET_FORMULA_PREFIX = /^'(?='*[=+\-@\t\r\n=+-@])/; export function splitListValues(rawValue: string): string[] { return rawValue @@ -60,7 +58,7 @@ export function parseSingleColumnCSV(content: string): string[] { if (containsListValueSeparator(normalizedValue)) { throw new Error('CSV values cannot contain commas, tabs, or line breaks'); } - values.push(normalizedValue.replace(ESCAPED_SPREADSHEET_FORMULA_PREFIX, '')); + values.push(normalizedValue); } value = ''; hasClosedQuote = false; diff --git a/packages/frontend/projects/upgrade/src/app/shared/services/common-export-helpers.service.spec.ts b/packages/frontend/projects/upgrade/src/app/shared/services/common-export-helpers.service.spec.ts deleted file mode 100644 index e251f5d543..0000000000 --- a/packages/frontend/projects/upgrade/src/app/shared/services/common-export-helpers.service.spec.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { serializeValuesAsCSV } from './common-export-helpers.service'; - -describe('CommonExportHelpersService', () => { - describe('serializeValuesAsCSV', () => { - it('neutralizes values that spreadsheet applications could interpret as formulas', () => { - expect( - serializeValuesAsCSV([ - 'plain', - '=SUM(A1:A2)', - '+cmd', - '-1+2', - '@SUM(A1:A2)', - '\tformula', - '\rformula', - '\nformula', - '=SUM(A1:A2)', - ]) - ).toBe( - [ - '"plain"', - '"\'=SUM(A1:A2)"', - '"\'+cmd"', - '"\'-1+2"', - '"\'@SUM(A1:A2)"', - '"\'\tformula"', - '"\'\rformula"', - '"\'\nformula"', - '"\'=SUM(A1:A2)"', - ].join('\r\n') - ); - }); - - it('escapes CSV control characters and preserves genuine leading apostrophes', () => { - expect(serializeValuesAsCSV(['plain', 'one,two', 'say "hello"', 'line\nbreak'])).toBe( - ['"plain"', '"one,two"', '"say ""hello"""', '"line\nbreak"'].join('\r\n') - ); - expect(serializeValuesAsCSV(["'=SUM(A1:A2)", "''=SUM(A1:A2)", "'school"])).toBe( - ['"\'\'=SUM(A1:A2)"', "\"'''=SUM(A1:A2)\"", '"\'school"'].join('\r\n') - ); - }); - }); -}); diff --git a/packages/frontend/projects/upgrade/src/app/shared/services/common-export-helpers.service.ts b/packages/frontend/projects/upgrade/src/app/shared/services/common-export-helpers.service.ts index 3b6cd69f57..6316a62ea6 100644 --- a/packages/frontend/projects/upgrade/src/app/shared/services/common-export-helpers.service.ts +++ b/packages/frontend/projects/upgrade/src/app/shared/services/common-export-helpers.service.ts @@ -1,13 +1,8 @@ import { Injectable } from '@angular/core'; import JSZip from 'jszip'; -// Prefixing an apostrophe is the spreadsheet convention for treating formula-like cells as text. -// Match existing apostrophes too so adding one remains reversible when the CSV is imported again. -const SPREADSHEET_FORMULA_PREFIX = /^'*[=+\-@\t\r\n=+-@]/; - function escapeCSVField(value: string): string { - const safeValue = SPREADSHEET_FORMULA_PREFIX.test(value) ? `'${value}` : value; - return `"${safeValue.replace(/"/g, '""')}"`; + return `"${value.replace(/"/g, '""')}"`; } export function serializeValuesAsCSV(values: string[]): string { From 48aed1c93955a1c5ac2ab2a89830422160241f0d Mon Sep 17 00:00:00 2001 From: Zack Lee Date: Fri, 21 Aug 2026 18:50:35 -0400 Subject: [PATCH 13/33] refactor: preserve the existing list delete API contract --- .../api/controllers/ExperimentController.ts | 31 +-------------- .../api/controllers/FeatureFlagController.ts | 35 ++--------------- .../validators/ListOwnerInputValidator.ts | 7 ---- .../src/api/services/ExperimentService.ts | 8 ++-- .../src/api/services/FeatureFlagService.ts | 24 +++--------- .../GroupExperimentExclusionCode.ts | 1 - .../controllers/ExperimentController.test.ts | 2 - .../controllers/FeatureFlagController.test.ts | 2 - .../unit/services/ExperimentService.test.ts | 39 ------------------- .../unit/services/FeatureFlagService.test.ts | 19 +-------- .../experiments.data.service.spec.ts | 22 ----------- .../experiments/experiments.data.service.ts | 8 ++-- .../core/experiments/experiments.service.ts | 8 ++-- .../experiments/store/experiments.actions.ts | 4 +- .../experiments/store/experiments.effects.ts | 10 +++-- .../feature-flags.data.service.ts | 8 ++-- .../feature-flags/feature-flags.service.ts | 8 ++-- .../store/feature-flags.actions.ts | 4 +- .../store/feature-flags.effects.ts | 10 +++-- .../list-details.data.service.spec.ts | 4 +- .../segments/list-details.data.service.ts | 8 ++-- ...iment-exclusions-section-card.component.ts | 6 +-- ...iment-inclusions-section-card.component.ts | 6 +-- ...-flag-exclusions-section-card.component.ts | 6 +-- ...-flag-inclusions-section-card.component.ts | 6 +-- postman/PlatformAPI.postman_collection.json | 8 ---- 26 files changed, 66 insertions(+), 228 deletions(-) delete mode 100644 packages/backend/src/api/controllers/validators/ListOwnerInputValidator.ts diff --git a/packages/backend/src/api/controllers/ExperimentController.ts b/packages/backend/src/api/controllers/ExperimentController.ts index a874b528ae..fa4cb0a5ca 100644 --- a/packages/backend/src/api/controllers/ExperimentController.ts +++ b/packages/backend/src/api/controllers/ExperimentController.ts @@ -49,7 +49,6 @@ import { Segment } from '../models/Segment'; import { MoocletRewardsService } from '../services/MoocletRewardsService'; import { ExperimentRewardsSummary } from 'upgrade_types'; import { CacheService } from '../services/CacheService'; -import { ListOwnerInputValidator } from './validators/ListOwnerInputValidator'; interface ExperimentPaginationInfo extends PaginationResponse { nodes: Experiment[]; @@ -1748,18 +1747,6 @@ export class ExperimentController { * schema: * type: string * description: Segment Id of private segment - * - in: body - * name: owner - * required: true - * schema: - * type: object - * required: - * - ownerId - * properties: - * ownerId: - * type: string - * format: uuid - * description: Experiment that owns the list * tags: * - Experiments * produces: @@ -1771,11 +1758,10 @@ export class ExperimentController { @Delete('/inclusionList/:id') public async deleteInclusionList( @Params({ validate: true }) { id }: IdValidator, - @Body({ validate: true }) { ownerId }: ListOwnerInputValidator, @CurrentUser() currentUser: UserDTO, @Req() request: AppRequest ): Promise { - return this.experimentService.deleteList(id, ownerId, LIST_FILTER_MODE.INCLUSION, currentUser, request.logger); + return this.experimentService.deleteList(id, LIST_FILTER_MODE.INCLUSION, currentUser, request.logger); } /** @@ -1792,18 +1778,6 @@ export class ExperimentController { * schema: * type: string * description: Segment Id of private segment - * - in: body - * name: owner - * required: true - * schema: - * type: object - * required: - * - ownerId - * properties: - * ownerId: - * type: string - * format: uuid - * description: Experiment that owns the list * tags: * - Experiments * produces: @@ -1815,11 +1789,10 @@ export class ExperimentController { @Delete('/exclusionList/:id') public async deleteExclusionList( @Params({ validate: true }) { id }: IdValidator, - @Body({ validate: true }) { ownerId }: ListOwnerInputValidator, @CurrentUser() currentUser: UserDTO, @Req() request: AppRequest ): Promise { - return this.experimentService.deleteList(id, ownerId, LIST_FILTER_MODE.EXCLUSION, currentUser, request.logger); + return this.experimentService.deleteList(id, LIST_FILTER_MODE.EXCLUSION, currentUser, request.logger); } /** diff --git a/packages/backend/src/api/controllers/FeatureFlagController.ts b/packages/backend/src/api/controllers/FeatureFlagController.ts index 078f27e715..10c74754d5 100644 --- a/packages/backend/src/api/controllers/FeatureFlagController.ts +++ b/packages/backend/src/api/controllers/FeatureFlagController.ts @@ -36,7 +36,6 @@ import { Response } from 'express'; import { UserDTO } from '../DTO/UserDTO'; import { NotFoundException } from '@nestjs/common/exceptions'; import { SegmentInputValidator } from './validators/SegmentInputValidator'; -import { ListOwnerInputValidator } from './validators/ListOwnerInputValidator'; interface FeatureFlagsPaginationInfo extends PaginationResponse { nodes: FeatureFlag[]; @@ -725,7 +724,7 @@ export class FeatureFlagsController { /** * @swagger - * /flags/inclusionList/{id}: + * /flags/inclusionList: * delete: * description: Delete Feature Flag Inclusion List * consumes: @@ -737,18 +736,6 @@ export class FeatureFlagsController { * schema: * type: string * description: Segment Id of private segment - * - in: body - * name: owner - * required: true - * schema: - * type: object - * required: - * - ownerId - * properties: - * ownerId: - * type: string - * format: uuid - * description: Feature flag that owns the list * tags: * - Feature Flags * produces: @@ -760,16 +747,15 @@ export class FeatureFlagsController { @Delete('/inclusionList/:id') public async deleteInclusionList( @Params({ validate: true }) { id }: IdValidator, - @Body({ validate: true }) { ownerId }: ListOwnerInputValidator, @CurrentUser() currentUser: UserDTO, @Req() request: AppRequest ): Promise { - return this.featureFlagService.deleteList(id, ownerId, LIST_FILTER_MODE.INCLUSION, currentUser, request.logger); + return this.featureFlagService.deleteList(id, LIST_FILTER_MODE.INCLUSION, currentUser, request.logger); } /** * @swagger - * /flags/exclusionList/{id}: + * /flags/exclusionList: * delete: * description: Delete Feature Flag Exclusion List * consumes: @@ -781,18 +767,6 @@ export class FeatureFlagsController { * schema: * type: string * description: Segment Id of private segment - * - in: body - * name: owner - * required: true - * schema: - * type: object - * required: - * - ownerId - * properties: - * ownerId: - * type: string - * format: uuid - * description: Feature flag that owns the list * tags: * - Feature Flags * produces: @@ -804,11 +778,10 @@ export class FeatureFlagsController { @Delete('/exclusionList/:id') public async deleteExclusionList( @Params({ validate: true }) { id }: IdValidator, - @Body({ validate: true }) { ownerId }: ListOwnerInputValidator, @CurrentUser() currentUser: UserDTO, @Req() request: AppRequest ): Promise { - return this.featureFlagService.deleteList(id, ownerId, LIST_FILTER_MODE.EXCLUSION, currentUser, request.logger); + return this.featureFlagService.deleteList(id, LIST_FILTER_MODE.EXCLUSION, currentUser, request.logger); } /** diff --git a/packages/backend/src/api/controllers/validators/ListOwnerInputValidator.ts b/packages/backend/src/api/controllers/validators/ListOwnerInputValidator.ts deleted file mode 100644 index ecd490cfcf..0000000000 --- a/packages/backend/src/api/controllers/validators/ListOwnerInputValidator.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { IsNotEmpty, IsUUID } from 'class-validator'; - -export class ListOwnerInputValidator { - @IsNotEmpty() - @IsUUID() - public ownerId: string; -} diff --git a/packages/backend/src/api/services/ExperimentService.ts b/packages/backend/src/api/services/ExperimentService.ts index 7d72f49519..8d0352d905 100644 --- a/packages/backend/src/api/services/ExperimentService.ts +++ b/packages/backend/src/api/services/ExperimentService.ts @@ -2122,17 +2122,15 @@ export class ExperimentService { public async deleteList( segmentId: string, - experimentId: string, filterType: LIST_FILTER_MODE, currentUser: UserDTO, logger: UpgradeLogger ): Promise { const existingRecords = await this.getExistingInclusionExclusionSegments([segmentId], filterType); - const existingRecord = existingRecords.find((record) => record.experiment.id === experimentId); - if (!existingRecord) { - throw new Error(`Segment with ID ${segmentId} not found for experiment ${experimentId} and ${filterType}`); + if (existingRecords.length === 0) { + throw new Error(`Segment with ID ${segmentId} not found for ${filterType}`); } - await this.createDeleteListAuditLogs([existingRecord], filterType, currentUser); + await this.createDeleteListAuditLogs(existingRecords, filterType, currentUser); await this.cacheService.resetPrefixCache(CACHE_PREFIX.FEATURE_FLAG_KEY_PREFIX); return this.segmentService.deleteSegment(segmentId, logger); } diff --git a/packages/backend/src/api/services/FeatureFlagService.ts b/packages/backend/src/api/services/FeatureFlagService.ts index ba1e51bd1c..2889fe8469 100644 --- a/packages/backend/src/api/services/FeatureFlagService.ts +++ b/packages/backend/src/api/services/FeatureFlagService.ts @@ -544,17 +544,13 @@ export class FeatureFlagService { // Create delete audit logs for inclusion and exclusion lists if (includeListIds.length) { promises.push( - this.createDeleteListAuditLogs(includeListIds, LIST_FILTER_MODE.INCLUSION, user, { - entityManager: transactionalEntityManager, - }) + this.createDeleteListAuditLogs(includeListIds, LIST_FILTER_MODE.INCLUSION, user, transactionalEntityManager) ); } if (excludeListIds.length) { promises.push( - this.createDeleteListAuditLogs(excludeListIds, LIST_FILTER_MODE.EXCLUSION, user, { - entityManager: transactionalEntityManager, - }) + this.createDeleteListAuditLogs(excludeListIds, LIST_FILTER_MODE.EXCLUSION, user, transactionalEntityManager) ); } @@ -608,12 +604,11 @@ export class FeatureFlagService { public async deleteList( segmentId: string, - featureFlagId: string, filterType: LIST_FILTER_MODE, currentUser: UserDTO, logger: UpgradeLogger ): Promise { - await this.createDeleteListAuditLogs([segmentId], filterType, currentUser, { featureFlagId }); + await this.createDeleteListAuditLogs([segmentId], filterType, currentUser); await this.cacheService.resetPrefixCache(CACHE_PREFIX.FEATURE_FLAG_KEY_PREFIX); // segmentService.deleteSegment collects the affected flags before deletion and fires the @@ -625,9 +620,8 @@ export class FeatureFlagService { segmentIds: string[], filterType: LIST_FILTER_MODE, currentUser: UserDTO, - options: { entityManager?: EntityManager; featureFlagId?: string } = {} + entityManager?: EntityManager ): Promise { - const { entityManager, featureFlagId } = options; const auditLogPromises = []; for (const segmentId of segmentIds) { @@ -635,10 +629,7 @@ export class FeatureFlagService { if (filterType === LIST_FILTER_MODE.INCLUSION) { existingRecord = await this.featureFlagSegmentInclusionRepository.findOne({ - where: { - segment: { id: segmentId }, - ...(featureFlagId ? { featureFlag: { id: featureFlagId } } : {}), - }, + where: { segment: { id: segmentId } }, relations: { featureFlag: true, segment: true, @@ -646,10 +637,7 @@ export class FeatureFlagService { }); } else { existingRecord = await this.featureFlagSegmentExclusionRepository.findOne({ - where: { - segment: { id: segmentId }, - ...(featureFlagId ? { featureFlag: { id: featureFlagId } } : {}), - }, + where: { segment: { id: segmentId } }, relations: { featureFlag: true, segment: true, diff --git a/packages/backend/test/integration/Experiment/exclusionCode/GroupExperimentExclusionCode.ts b/packages/backend/test/integration/Experiment/exclusionCode/GroupExperimentExclusionCode.ts index 5b717cc597..6b4d87e286 100644 --- a/packages/backend/test/integration/Experiment/exclusionCode/GroupExperimentExclusionCode.ts +++ b/packages/backend/test/integration/Experiment/exclusionCode/GroupExperimentExclusionCode.ts @@ -105,7 +105,6 @@ export default async function testCase(): Promise { await experimentService.deleteList( experimentObject.experimentSegmentExclusion[0].segment.id, - experimentId, LIST_FILTER_MODE.EXCLUSION, user, new UpgradeLogger() diff --git a/packages/backend/test/unit/controllers/ExperimentController.test.ts b/packages/backend/test/unit/controllers/ExperimentController.test.ts index 3ca0704731..f5990855f0 100644 --- a/packages/backend/test/unit/controllers/ExperimentController.test.ts +++ b/packages/backend/test/unit/controllers/ExperimentController.test.ts @@ -305,7 +305,6 @@ describe('Experiment Controller Testing', () => { test('Delete request for /api/experiments/inclusionList/id', () => { return request(app) .delete('/api/experiments/inclusionList/' + crypto.randomUUID()) - .send({ ownerId: crypto.randomUUID() }) .set('Accept', 'application/json') .expect('Content-Type', /json/) .expect(200); @@ -314,7 +313,6 @@ describe('Experiment Controller Testing', () => { test('Delete request for /api/experiments/exclusionList/id', () => { return request(app) .delete('/api/experiments/exclusionList/' + crypto.randomUUID()) - .send({ ownerId: crypto.randomUUID() }) .set('Accept', 'application/json') .expect('Content-Type', /json/) .expect(200); diff --git a/packages/backend/test/unit/controllers/FeatureFlagController.test.ts b/packages/backend/test/unit/controllers/FeatureFlagController.test.ts index 9dfbb18bf5..c09f639acc 100644 --- a/packages/backend/test/unit/controllers/FeatureFlagController.test.ts +++ b/packages/backend/test/unit/controllers/FeatureFlagController.test.ts @@ -180,7 +180,6 @@ describe('Feature Flag Controller Testing', () => { test('Delete request for /api/flags/inclusionList/id', () => { return request(app) .delete('/api/flags/inclusionList/' + crypto.randomUUID()) - .send({ ownerId: crypto.randomUUID() }) .set('Accept', 'application/json') .expect('Content-Type', /json/) .expect(200); @@ -189,7 +188,6 @@ describe('Feature Flag Controller Testing', () => { test('Delete request for /api/flags/exclusionList/id', () => { return request(app) .delete('/api/flags/exclusionList/' + crypto.randomUUID()) - .send({ ownerId: crypto.randomUUID() }) .set('Accept', 'application/json') .expect('Content-Type', /json/) .expect(200); diff --git a/packages/backend/test/unit/services/ExperimentService.test.ts b/packages/backend/test/unit/services/ExperimentService.test.ts index 67cfe67517..d541b0c798 100644 --- a/packages/backend/test/unit/services/ExperimentService.test.ts +++ b/packages/backend/test/unit/services/ExperimentService.test.ts @@ -473,45 +473,6 @@ describe('ExperimentService Testing', () => { jest.clearAllMocks(); }); - describe('deleteList', () => { - it('deletes a list attached to the requested experiment and filter mode', async () => { - const inclusionRepo = module.get( - getRepositoryToken(ExperimentSegmentInclusionRepository) - ); - const segmentService = module.get(SegmentService); - (inclusionRepo.getExistingSegments as jest.Mock).mockResolvedValue([ - { - experiment: { id: mockExperiment.id, name: mockExperiment.name }, - segment: { id: 'list-1', name: 'List 1' }, - }, - ]); - - await service.deleteList('list-1', mockExperiment.id, LIST_FILTER_MODE.INCLUSION, mockUser, logger); - - expect(segmentService.deleteSegment).toHaveBeenCalledWith('list-1', logger); - }); - - it('does not delete a list attached to a different experiment', async () => { - const inclusionRepo = module.get( - getRepositoryToken(ExperimentSegmentInclusionRepository) - ); - const segmentService = module.get(SegmentService); - (inclusionRepo.getExistingSegments as jest.Mock).mockResolvedValue([ - { - experiment: { id: 'different-experiment', name: 'Different experiment' }, - segment: { id: 'list-1', name: 'List 1' }, - }, - ]); - - await expect( - service.deleteList('list-1', mockExperiment.id, LIST_FILTER_MODE.INCLUSION, mockUser, logger) - ).rejects.toThrow( - `Segment with ID list-1 not found for experiment ${mockExperiment.id} and ${LIST_FILTER_MODE.INCLUSION}` - ); - expect(segmentService.deleteSegment).not.toHaveBeenCalled(); - }); - }); - describe('legacy list type inference', () => { it('normalizes an existing standard list type', () => { const segment = { listType: 'iNdIvIdUaL' } as Segment; diff --git a/packages/backend/test/unit/services/FeatureFlagService.test.ts b/packages/backend/test/unit/services/FeatureFlagService.test.ts index b4520cce30..bba93aa150 100644 --- a/packages/backend/test/unit/services/FeatureFlagService.test.ts +++ b/packages/backend/test/unit/services/FeatureFlagService.test.ts @@ -606,28 +606,11 @@ describe('Feature Flag Service Testing', () => { }); it('should delete an include list', async () => { - const result = await service.deleteList( - mockList.segment.id, - mockFlag1.id, - LIST_FILTER_MODE.INCLUSION, - mockUser1, - logger - ); + const result = await service.deleteList(mockList.segment.id, LIST_FILTER_MODE.INCLUSION, mockUser1, logger); expect(result).toBeTruthy(); }); - it('should not delete an include list from a different feature flag', async () => { - const inclusionRepo = module.get(getRepositoryToken(FeatureFlagSegmentInclusionRepository)) as any; - const segmentService = module.get(SegmentService); - inclusionRepo.findOne = jest.fn().mockResolvedValue(undefined); - - await expect( - service.deleteList(mockList.segment.id, mockFlag1.id, LIST_FILTER_MODE.INCLUSION, mockUser1, logger) - ).rejects.toThrow(`Segment with ID ${mockList.segment.id} not found for ${LIST_FILTER_MODE.INCLUSION}`); - expect(segmentService.deleteSegment).not.toHaveBeenCalled(); - }); - it('should find one flag for the details view', async () => { const result = await service.findOneForDetails(mockFlag1.id, logger); expect(result).toEqual(mockFlag1); diff --git a/packages/frontend/projects/upgrade/src/app/core/experiments/experiments.data.service.spec.ts b/packages/frontend/projects/upgrade/src/app/core/experiments/experiments.data.service.spec.ts index a5fbf1c173..0d175d5319 100644 --- a/packages/frontend/projects/upgrade/src/app/core/experiments/experiments.data.service.spec.ts +++ b/packages/frontend/projects/upgrade/src/app/core/experiments/experiments.data.service.spec.ts @@ -321,26 +321,4 @@ describe('ExperimentDataService', () => { expect(mockHttpClient.get).toHaveBeenCalledWith(expectedUrl); }); }); - - describe('#deleteInclusionList', () => { - it('includes the experiment id in the delete request', () => { - const segmentId = 'segment-id'; - const expectedUrl = `${API_ENDPOINTS.addExperimentInclusionList}/${segmentId}`; - - service.deleteInclusionList(segmentId, mockExperimentId); - - expect(mockHttpClient.delete).toHaveBeenCalledWith(expectedUrl, { body: { ownerId: mockExperimentId } }); - }); - }); - - describe('#deleteExclusionList', () => { - it('includes the experiment id in the delete request', () => { - const segmentId = 'segment-id'; - const expectedUrl = `${API_ENDPOINTS.addExperimentExclusionList}/${segmentId}`; - - service.deleteExclusionList(segmentId, mockExperimentId); - - expect(mockHttpClient.delete).toHaveBeenCalledWith(expectedUrl, { body: { ownerId: mockExperimentId } }); - }); - }); }); diff --git a/packages/frontend/projects/upgrade/src/app/core/experiments/experiments.data.service.ts b/packages/frontend/projects/upgrade/src/app/core/experiments/experiments.data.service.ts index 220fcba58a..52be0ad3b0 100644 --- a/packages/frontend/projects/upgrade/src/app/core/experiments/experiments.data.service.ts +++ b/packages/frontend/projects/upgrade/src/app/core/experiments/experiments.data.service.ts @@ -136,9 +136,9 @@ export class ExperimentDataService { return this.http.put(url, list); } - deleteInclusionList(segmentId: string, experimentId: string) { + deleteInclusionList(segmentId: string) { const url = `${API_ENDPOINTS.addExperimentInclusionList}/${segmentId}`; - return this.http.delete(url, { body: { ownerId: experimentId } }); + return this.http.delete(url); } addExclusionList(list: ExperimentSegmentListRequest): Observable { @@ -151,9 +151,9 @@ export class ExperimentDataService { return this.http.put(url, list); } - deleteExclusionList(segmentId: string, experimentId: string) { + deleteExclusionList(segmentId: string) { const url = `${API_ENDPOINTS.addExperimentExclusionList}/${segmentId}`; - return this.http.delete(url, { body: { ownerId: experimentId } }); + return this.http.delete(url); } fetchContextMetaData() { diff --git a/packages/frontend/projects/upgrade/src/app/core/experiments/experiments.service.ts b/packages/frontend/projects/upgrade/src/app/core/experiments/experiments.service.ts index 6bce5abff1..d1c4f559de 100644 --- a/packages/frontend/projects/upgrade/src/app/core/experiments/experiments.service.ts +++ b/packages/frontend/projects/upgrade/src/app/core/experiments/experiments.service.ts @@ -257,8 +257,8 @@ export class ExperimentService { this.store$.dispatch(experimentAction.actionUpdateExperimentInclusionList({ list })); } - deleteExperimentInclusionPrivateSegmentList(segmentId: string, experimentId: string) { - this.store$.dispatch(experimentAction.actionDeleteExperimentInclusionList({ segmentId, experimentId })); + deleteExperimentInclusionPrivateSegmentList(segmentId: string) { + this.store$.dispatch(experimentAction.actionDeleteExperimentInclusionList({ segmentId })); } addExperimentExclusionPrivateSegmentList(list: ExperimentSegmentListRequest) { @@ -269,8 +269,8 @@ export class ExperimentService { this.store$.dispatch(experimentAction.actionUpdateExperimentExclusionList({ list })); } - deleteExperimentExclusionPrivateSegmentList(segmentId: string, experimentId: string) { - this.store$.dispatch(experimentAction.actionDeleteExperimentExclusionList({ segmentId, experimentId })); + deleteExperimentExclusionPrivateSegmentList(segmentId: string) { + this.store$.dispatch(experimentAction.actionDeleteExperimentExclusionList({ segmentId })); } updateExperimentConditionWeights(experiment: ExperimentVM, weightUpdates: ConditionWeightUpdate[]): void { diff --git a/packages/frontend/projects/upgrade/src/app/core/experiments/store/experiments.actions.ts b/packages/frontend/projects/upgrade/src/app/core/experiments/store/experiments.actions.ts index 3d73d0801d..c562464d9c 100644 --- a/packages/frontend/projects/upgrade/src/app/core/experiments/store/experiments.actions.ts +++ b/packages/frontend/projects/upgrade/src/app/core/experiments/store/experiments.actions.ts @@ -306,7 +306,7 @@ export const actionUpdateExperimentInclusionListFailure = createAction( export const actionDeleteExperimentInclusionList = createAction( '[Experiment] Delete Experiment Inclusion List', - props<{ segmentId: string; experimentId: string }>() + props<{ segmentId: string }>() ); export const actionDeleteExperimentInclusionListSuccess = createAction( @@ -351,7 +351,7 @@ export const actionUpdateExperimentExclusionListFailure = createAction( export const actionDeleteExperimentExclusionList = createAction( '[Experiment] Delete Experiment Exclusion List', - props<{ segmentId: string; experimentId: string }>() + props<{ segmentId: string }>() ); export const actionDeleteExperimentExclusionListSuccess = createAction( diff --git a/packages/frontend/projects/upgrade/src/app/core/experiments/store/experiments.effects.ts b/packages/frontend/projects/upgrade/src/app/core/experiments/store/experiments.effects.ts index 3c6091d162..ae3a3a20a6 100644 --- a/packages/frontend/projects/upgrade/src/app/core/experiments/store/experiments.effects.ts +++ b/packages/frontend/projects/upgrade/src/app/core/experiments/store/experiments.effects.ts @@ -605,8 +605,9 @@ export class ExperimentEffects { deleteExperimentInclusionList$ = createEffect(() => this.actions$.pipe( ofType(experimentAction.actionDeleteExperimentInclusionList), - switchMap(({ segmentId, experimentId }) => { - return this.experimentDataService.deleteInclusionList(segmentId, experimentId).pipe( + map((action) => action.segmentId), + switchMap((segmentId) => { + return this.experimentDataService.deleteInclusionList(segmentId).pipe( map(() => { this.notificationService.showSuccess(this.translate.instant('experiments.inclusions.delete-success.text')); return experimentAction.actionDeleteExperimentInclusionListSuccess({ segmentId }); @@ -671,8 +672,9 @@ export class ExperimentEffects { deleteExperimentExclusionList$ = createEffect(() => this.actions$.pipe( ofType(experimentAction.actionDeleteExperimentExclusionList), - switchMap(({ segmentId, experimentId }) => { - return this.experimentDataService.deleteExclusionList(segmentId, experimentId).pipe( + map((action) => action.segmentId), + switchMap((segmentId) => { + return this.experimentDataService.deleteExclusionList(segmentId).pipe( map(() => { this.notificationService.showSuccess(this.translate.instant('experiments.exclusions.delete-success.text')); return experimentAction.actionDeleteExperimentExclusionListSuccess({ segmentId }); diff --git a/packages/frontend/projects/upgrade/src/app/core/feature-flags/feature-flags.data.service.ts b/packages/frontend/projects/upgrade/src/app/core/feature-flags/feature-flags.data.service.ts index d94c9de8ee..fc751c59dd 100644 --- a/packages/frontend/projects/upgrade/src/app/core/feature-flags/feature-flags.data.service.ts +++ b/packages/frontend/projects/upgrade/src/app/core/feature-flags/feature-flags.data.service.ts @@ -120,9 +120,9 @@ export class FeatureFlagsDataService { return this.http.put(url, list); } - deleteInclusionList(segmentId: string, flagId: string) { + deleteInclusionList(segmentId: string) { const url = `${API_ENDPOINTS.addFlagInclusionList}/${segmentId}`; - return this.http.delete(url, { body: { ownerId: flagId } }); + return this.http.delete(url); } updateInclusionListStatus(segmentId: string, enabled: boolean) { @@ -140,9 +140,9 @@ export class FeatureFlagsDataService { return this.http.put(url, list); } - deleteExclusionList(segmentId: string, flagId: string) { + deleteExclusionList(segmentId: string) { const url = `${API_ENDPOINTS.addFlagExclusionList}/${segmentId}`; - return this.http.delete(url, { body: { ownerId: flagId } }); + return this.http.delete(url); } updateExclusionListStatus(segmentId: string, enabled: boolean) { diff --git a/packages/frontend/projects/upgrade/src/app/core/feature-flags/feature-flags.service.ts b/packages/frontend/projects/upgrade/src/app/core/feature-flags/feature-flags.service.ts index c5f2cd9c50..64d8324d63 100644 --- a/packages/frontend/projects/upgrade/src/app/core/feature-flags/feature-flags.service.ts +++ b/packages/frontend/projects/upgrade/src/app/core/feature-flags/feature-flags.service.ts @@ -183,8 +183,8 @@ export class FeatureFlagsService { this.store$.dispatch(FeatureFlagsActions.actionUpdateFeatureFlagInclusionList({ list })); } - deleteFeatureFlagInclusionPrivateSegmentList(segmentId: string, flagId: string) { - this.store$.dispatch(FeatureFlagsActions.actionDeleteFeatureFlagInclusionList({ segmentId, flagId })); + deleteFeatureFlagInclusionPrivateSegmentList(segmentId: string) { + this.store$.dispatch(FeatureFlagsActions.actionDeleteFeatureFlagInclusionList({ segmentId })); } updateFeatureFlagInclusionListStatus(segmentId: string, enabled: boolean) { @@ -199,8 +199,8 @@ export class FeatureFlagsService { this.store$.dispatch(FeatureFlagsActions.actionUpdateFeatureFlagExclusionList({ list })); } - deleteFeatureFlagExclusionPrivateSegmentList(segmentId: string, flagId: string) { - this.store$.dispatch(FeatureFlagsActions.actionDeleteFeatureFlagExclusionList({ segmentId, flagId })); + deleteFeatureFlagExclusionPrivateSegmentList(segmentId: string) { + this.store$.dispatch(FeatureFlagsActions.actionDeleteFeatureFlagExclusionList({ segmentId })); } updateFeatureFlagExclusionListStatus(segmentId: string, enabled: boolean) { diff --git a/packages/frontend/projects/upgrade/src/app/core/feature-flags/store/feature-flags.actions.ts b/packages/frontend/projects/upgrade/src/app/core/feature-flags/store/feature-flags.actions.ts index 7fce953188..30d846ac11 100644 --- a/packages/frontend/projects/upgrade/src/app/core/feature-flags/store/feature-flags.actions.ts +++ b/packages/frontend/projects/upgrade/src/app/core/feature-flags/store/feature-flags.actions.ts @@ -217,7 +217,7 @@ export const actionUpdateFeatureFlagInclusionListStatusFailure = createAction( export const actionDeleteFeatureFlagInclusionList = createAction( '[Feature Flags] Delete Feature Flag Inclusion List', - props<{ segmentId: string; flagId: string }>() + props<{ segmentId: string }>() ); export const actionDeleteFeatureFlagInclusionListSuccess = createAction( @@ -277,7 +277,7 @@ export const actionUpdateFeatureFlagExclusionListStatusFailure = createAction( export const actionDeleteFeatureFlagExclusionList = createAction( '[Feature Flags] Delete Feature Flag Exclusion List', - props<{ segmentId: string; flagId: string }>() + props<{ segmentId: string }>() ); export const actionDeleteFeatureFlagExclusionListSuccess = createAction( diff --git a/packages/frontend/projects/upgrade/src/app/core/feature-flags/store/feature-flags.effects.ts b/packages/frontend/projects/upgrade/src/app/core/feature-flags/store/feature-flags.effects.ts index 70fbd57588..7835760fe0 100644 --- a/packages/frontend/projects/upgrade/src/app/core/feature-flags/store/feature-flags.effects.ts +++ b/packages/frontend/projects/upgrade/src/app/core/feature-flags/store/feature-flags.effects.ts @@ -255,8 +255,9 @@ export class FeatureFlagsEffects { deleteFeatureFlagInclusionList$ = createEffect(() => this.actions$.pipe( ofType(FeatureFlagsActions.actionDeleteFeatureFlagInclusionList), - switchMap(({ segmentId, flagId }) => { - return this.featureFlagsDataService.deleteInclusionList(segmentId, flagId).pipe( + map((action) => action.segmentId), + switchMap((segmentId) => { + return this.featureFlagsDataService.deleteInclusionList(segmentId).pipe( map(() => { this.notificationService.showSuccess( this.translate.instant('feature-flags.inclusions.delete-success.text') @@ -325,8 +326,9 @@ export class FeatureFlagsEffects { deleteFeatureFlagExclusionList$ = createEffect(() => this.actions$.pipe( ofType(FeatureFlagsActions.actionDeleteFeatureFlagExclusionList), - switchMap(({ segmentId, flagId }) => { - return this.featureFlagsDataService.deleteExclusionList(segmentId, flagId).pipe( + map((action) => action.segmentId), + switchMap((segmentId) => { + return this.featureFlagsDataService.deleteExclusionList(segmentId).pipe( map(() => { this.notificationService.showSuccess( this.translate.instant('feature-flags.exclusions.delete-success.text') diff --git a/packages/frontend/projects/upgrade/src/app/core/segments/list-details.data.service.spec.ts b/packages/frontend/projects/upgrade/src/app/core/segments/list-details.data.service.spec.ts index 0d459399c2..aa88d36721 100644 --- a/packages/frontend/projects/upgrade/src/app/core/segments/list-details.data.service.spec.ts +++ b/packages/frontend/projects/upgrade/src/app/core/segments/list-details.data.service.spec.ts @@ -249,7 +249,7 @@ describe('ListDetailsDataService', () => { service .deleteList(LIST_OWNER_TYPE.EXPERIMENT, LIST_FILTER_MODE.INCLUSION, 'experiment-id', segment.id) .subscribe(() => { - expect(experimentDataService.deleteInclusionList).toHaveBeenCalledWith(segment.id, 'experiment-id'); + expect(experimentDataService.deleteInclusionList).toHaveBeenCalledWith(segment.id); done(); }); }); @@ -260,7 +260,7 @@ describe('ListDetailsDataService', () => { service .deleteList(LIST_OWNER_TYPE.FEATURE_FLAG, LIST_FILTER_MODE.EXCLUSION, 'flag-id', segment.id) .subscribe(() => { - expect(featureFlagsDataService.deleteExclusionList).toHaveBeenCalledWith(segment.id, 'flag-id'); + expect(featureFlagsDataService.deleteExclusionList).toHaveBeenCalledWith(segment.id); done(); }); }); diff --git a/packages/frontend/projects/upgrade/src/app/core/segments/list-details.data.service.ts b/packages/frontend/projects/upgrade/src/app/core/segments/list-details.data.service.ts index c64a4ae6da..330fac664c 100644 --- a/packages/frontend/projects/upgrade/src/app/core/segments/list-details.data.service.ts +++ b/packages/frontend/projects/upgrade/src/app/core/segments/list-details.data.service.ts @@ -152,14 +152,14 @@ export class ListDetailsDataService { deleteList(ownerType: LIST_OWNER_TYPE, filterMode: LIST_FILTER_MODE, ownerId: string, listId: string) { if (ownerType === LIST_OWNER_TYPE.EXPERIMENT) { return filterMode === LIST_FILTER_MODE.INCLUSION - ? this.experimentDataService.deleteInclusionList(listId, ownerId) - : this.experimentDataService.deleteExclusionList(listId, ownerId); + ? this.experimentDataService.deleteInclusionList(listId) + : this.experimentDataService.deleteExclusionList(listId); } if (ownerType === LIST_OWNER_TYPE.FEATURE_FLAG) { return filterMode === LIST_FILTER_MODE.INCLUSION - ? this.featureFlagsDataService.deleteInclusionList(listId, ownerId) - : this.featureFlagsDataService.deleteExclusionList(listId, ownerId); + ? this.featureFlagsDataService.deleteInclusionList(listId) + : this.featureFlagsDataService.deleteExclusionList(listId); } return this.segmentsDataService.deleteSegmentList(listId, ownerId); diff --git a/packages/frontend/projects/upgrade/src/app/features/dashboard/experiments/pages/experiment-details-page/experiment-details-page-content/experiment-exclusions-section-card/experiment-exclusions-section-card.component.ts b/packages/frontend/projects/upgrade/src/app/features/dashboard/experiments/pages/experiment-details-page/experiment-details-page-content/experiment-exclusions-section-card/experiment-exclusions-section-card.component.ts index 6a20fba324..94f25f5fe8 100644 --- a/packages/frontend/projects/upgrade/src/app/features/dashboard/experiments/pages/experiment-details-page/experiment-details-page-content/experiment-exclusions-section-card/experiment-exclusions-section-card.component.ts +++ b/packages/frontend/projects/upgrade/src/app/features/dashboard/experiments/pages/experiment-details-page/experiment-details-page-content/experiment-exclusions-section-card/experiment-exclusions-section-card.component.ts @@ -123,7 +123,7 @@ export class ExperimentExclusionsSectionCardComponent implements OnInit { this.onEditExcludeList(event.rowData, experimentId); break; case PARTICIPANT_LIST_ROW_ACTION.DELETE: - this.onDeleteExcludeList(event.rowData.segment, experimentId); + this.onDeleteExcludeList(event.rowData.segment); break; default: console.log('Unknown row action:', event.action); @@ -134,14 +134,14 @@ export class ExperimentExclusionsSectionCardComponent implements OnInit { this.dialogService.openExperimentEditExcludeListModal(rowData, rowData.segment.context, experimentId); } - onDeleteExcludeList(segment: Segment, experimentId: string): void { + onDeleteExcludeList(segment: Segment): void { this.dialogService .openDeleteExcludeListModal(segment.name) .afterClosed() .pipe(take(1)) .subscribe((confirmClicked) => { if (confirmClicked) { - this.experimentService.deleteExperimentExclusionPrivateSegmentList(segment.id, experimentId); + this.experimentService.deleteExperimentExclusionPrivateSegmentList(segment.id); } }); } diff --git a/packages/frontend/projects/upgrade/src/app/features/dashboard/experiments/pages/experiment-details-page/experiment-details-page-content/experiment-inclusions-section-card/experiment-inclusions-section-card.component.ts b/packages/frontend/projects/upgrade/src/app/features/dashboard/experiments/pages/experiment-details-page/experiment-details-page-content/experiment-inclusions-section-card/experiment-inclusions-section-card.component.ts index e2aa503bff..21324e074a 100644 --- a/packages/frontend/projects/upgrade/src/app/features/dashboard/experiments/pages/experiment-details-page/experiment-details-page-content/experiment-inclusions-section-card/experiment-inclusions-section-card.component.ts +++ b/packages/frontend/projects/upgrade/src/app/features/dashboard/experiments/pages/experiment-details-page/experiment-details-page-content/experiment-inclusions-section-card/experiment-inclusions-section-card.component.ts @@ -219,7 +219,7 @@ export class ExperimentInclusionsSectionCardComponent implements OnInit, OnDestr this.onEditIncludeList(event.rowData, experimentId); break; case PARTICIPANT_LIST_ROW_ACTION.DELETE: - this.onDeleteIncludeList(event.rowData.segment, experimentId); + this.onDeleteIncludeList(event.rowData.segment); break; default: console.log('Unknown action:', event.action); @@ -230,14 +230,14 @@ export class ExperimentInclusionsSectionCardComponent implements OnInit, OnDestr this.dialogService.openExperimentEditIncludeListModal(rowData, rowData.segment.context, experimentId); } - onDeleteIncludeList(segment: Segment, experimentId: string): void { + onDeleteIncludeList(segment: Segment): void { this.dialogService .openDeleteIncludeListModal(segment.name) .afterClosed() .pipe(take(1)) .subscribe((confirmClicked) => { if (confirmClicked) { - this.experimentService.deleteExperimentInclusionPrivateSegmentList(segment.id, experimentId); + this.experimentService.deleteExperimentInclusionPrivateSegmentList(segment.id); } }); } diff --git a/packages/frontend/projects/upgrade/src/app/features/dashboard/feature-flags/pages/feature-flag-details-page/feature-flag-details-page-content/feature-flag-exclusions-section-card/feature-flag-exclusions-section-card.component.ts b/packages/frontend/projects/upgrade/src/app/features/dashboard/feature-flags/pages/feature-flag-details-page/feature-flag-details-page-content/feature-flag-exclusions-section-card/feature-flag-exclusions-section-card.component.ts index 78f9e79c93..6c95f3e191 100644 --- a/packages/frontend/projects/upgrade/src/app/features/dashboard/feature-flags/pages/feature-flag-details-page/feature-flag-details-page-content/feature-flag-exclusions-section-card/feature-flag-exclusions-section-card.component.ts +++ b/packages/frontend/projects/upgrade/src/app/features/dashboard/feature-flags/pages/feature-flag-details-page/feature-flag-details-page-content/feature-flag-exclusions-section-card/feature-flag-exclusions-section-card.component.ts @@ -105,7 +105,7 @@ export class FeatureFlagExclusionsSectionCardComponent { this.onEditExcludeList(event.rowData, flagId); break; case PARTICIPANT_LIST_ROW_ACTION.DELETE: - this.onDeleteExcludeList(event.rowData.segment, flagId); + this.onDeleteExcludeList(event.rowData.segment); break; } } @@ -114,13 +114,13 @@ export class FeatureFlagExclusionsSectionCardComponent { this.dialogService.openFeatureFlagEditExcludeListModal(rowData, rowData.segment.context, flagId); } - onDeleteExcludeList(segment: Segment, flagId: string): void { + onDeleteExcludeList(segment: Segment): void { this.dialogService .openDeleteExcludeListModal(segment.name) .afterClosed() .subscribe((confirmClicked) => { if (confirmClicked) { - this.featureFlagService.deleteFeatureFlagExclusionPrivateSegmentList(segment.id, flagId); + this.featureFlagService.deleteFeatureFlagExclusionPrivateSegmentList(segment.id); } }); } diff --git a/packages/frontend/projects/upgrade/src/app/features/dashboard/feature-flags/pages/feature-flag-details-page/feature-flag-details-page-content/feature-flag-inclusions-section-card/feature-flag-inclusions-section-card.component.ts b/packages/frontend/projects/upgrade/src/app/features/dashboard/feature-flags/pages/feature-flag-details-page/feature-flag-details-page-content/feature-flag-inclusions-section-card/feature-flag-inclusions-section-card.component.ts index 88a662c6dd..369475e629 100644 --- a/packages/frontend/projects/upgrade/src/app/features/dashboard/feature-flags/pages/feature-flag-details-page/feature-flag-details-page-content/feature-flag-inclusions-section-card/feature-flag-inclusions-section-card.component.ts +++ b/packages/frontend/projects/upgrade/src/app/features/dashboard/feature-flags/pages/feature-flag-details-page/feature-flag-details-page-content/feature-flag-inclusions-section-card/feature-flag-inclusions-section-card.component.ts @@ -169,7 +169,7 @@ export class FeatureFlagInclusionsSectionCardComponent { this.onEditIncludeList(event.rowData, flagId); break; case PARTICIPANT_LIST_ROW_ACTION.DELETE: - this.onDeleteIncludeList(event.rowData.segment, flagId); + this.onDeleteIncludeList(event.rowData.segment); break; } } @@ -200,13 +200,13 @@ export class FeatureFlagInclusionsSectionCardComponent { this.dialogService.openFeatureFlagEditIncludeListModal(rowData, rowData.segment.context, flagId); } - onDeleteIncludeList(segment: Segment, flagId: string): void { + onDeleteIncludeList(segment: Segment): void { this.dialogService .openDeleteIncludeListModal(segment.name) .afterClosed() .subscribe((confirmClicked) => { if (confirmClicked) { - this.featureFlagService.deleteFeatureFlagInclusionPrivateSegmentList(segment.id, flagId); + this.featureFlagService.deleteFeatureFlagInclusionPrivateSegmentList(segment.id); } }); } diff --git a/postman/PlatformAPI.postman_collection.json b/postman/PlatformAPI.postman_collection.json index 34f3949d1f..26342b848f 100644 --- a/postman/PlatformAPI.postman_collection.json +++ b/postman/PlatformAPI.postman_collection.json @@ -463,10 +463,6 @@ "request": { "method": "DELETE", "header": [{ "key": "Content-Type", "value": "application/json" }], - "body": { - "mode": "raw", - "raw": "{\n \"ownerId\": \"{{flagId}}\"\n}" - }, "url": { "raw": "{{baseUrl}}/flags/inclusionList/segment-001", "host": ["{{baseUrl}}"], @@ -480,10 +476,6 @@ "request": { "method": "DELETE", "header": [{ "key": "Content-Type", "value": "application/json" }], - "body": { - "mode": "raw", - "raw": "{\n \"ownerId\": \"{{flagId}}\"\n}" - }, "url": { "raw": "{{baseUrl}}/flags/exclusionList/segment-002", "host": ["{{baseUrl}}"], From 6b68664d00859d398e2ce14ea22d9df54c707cb2 Mon Sep 17 00:00:00 2001 From: Zack Lee Date: Fri, 21 Aug 2026 19:00:06 -0400 Subject: [PATCH 14/33] test: remove obsolete list deletion mocks --- .../backend/test/unit/services/ExperimentService.test.ts | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/packages/backend/test/unit/services/ExperimentService.test.ts b/packages/backend/test/unit/services/ExperimentService.test.ts index d541b0c798..fcb83fb207 100644 --- a/packages/backend/test/unit/services/ExperimentService.test.ts +++ b/packages/backend/test/unit/services/ExperimentService.test.ts @@ -377,14 +377,12 @@ describe('ExperimentService Testing', () => { provide: getRepositoryToken(ExperimentSegmentInclusionRepository), useValue: { findOne: jest.fn().mockResolvedValue(null), - getExistingSegments: jest.fn().mockResolvedValue([]), }, }, { provide: getRepositoryToken(ExperimentSegmentExclusionRepository), useValue: { findOne: jest.fn().mockResolvedValue(null), - getExistingSegments: jest.fn().mockResolvedValue([]), }, }, { @@ -418,9 +416,7 @@ describe('ExperimentService Testing', () => { }, { provide: SegmentService, - useValue: { - deleteSegment: jest.fn().mockResolvedValue({ id: 'list-1' }), - }, + useValue: {}, }, { provide: ExperimentSchedulerService, From a0a4ff86b0a5dabc15fcc29b12bb0f3767177bcf Mon Sep 17 00:00:00 2001 From: Zack Lee Date: Fri, 21 Aug 2026 20:50:38 -0400 Subject: [PATCH 15/33] refactor: move experiment query optimization to a dedicated PR --- .../api/repositories/ExperimentRepository.ts | 392 ++++++------------ .../repositories/ExperimentRepository.test.ts | 162 ++------ 2 files changed, 180 insertions(+), 374 deletions(-) diff --git a/packages/backend/src/api/repositories/ExperimentRepository.ts b/packages/backend/src/api/repositories/ExperimentRepository.ts index 04612ef192..59ed97969e 100644 --- a/packages/backend/src/api/repositories/ExperimentRepository.ts +++ b/packages/backend/src/api/repositories/ExperimentRepository.ts @@ -24,15 +24,13 @@ export class ExperimentRepository extends Repository { .addOrderBy('queries.order', 'ASC', 'NULLS LAST') .addOrderBy('queries.createdAt', 'ASC'); - const experimentInclusionSegmentQuery = this.buildInclusionSegmentQuery(); - const experimentExclusionSegmentQuery = this.buildExclusionSegmentQuery(); + const experimentSegment = this.buildSegmentQuery(); const [ experimentConditionLevelPayloadData, experimentFactorPartitionLevelPayloadData, experimentMetricData, - experimentInclusionSegmentData, - experimentExclusionSegmentData, + experimentSegmentData, ] = await Promise.all([ experimentConditionLevelPayloadQuery.getMany().catch((errorMsg: any) => { const errorMsgString = repositoryError( @@ -61,19 +59,10 @@ export class ExperimentRepository extends Repository { ); throw errorMsgString; }), - experimentInclusionSegmentQuery.getMany().catch((errorMsg: any) => { + experimentSegment.getMany().catch((errorMsg: any) => { const errorMsgString = repositoryError( 'ExperimentRepository', - 'findAllExperiments-experimentInclusionSegmentData', - {}, - errorMsg - ); - throw errorMsgString; - }), - experimentExclusionSegmentQuery.getMany().catch((errorMsg: any) => { - const errorMsgString = repositoryError( - 'ExperimentRepository', - 'findAllExperiments-experimentExclusionSegmentData', + 'findAllExperiments-experimentSegmentData', {}, errorMsg ); @@ -81,8 +70,6 @@ export class ExperimentRepository extends Repository { }), ]); - const experimentSegmentData = this.mergeSegmentData(experimentInclusionSegmentData, experimentExclusionSegmentData); - const experimentData = experimentConditionLevelPayloadData.map((data) => { const data2 = experimentFactorPartitionLevelPayloadData.find((i) => i.id === data.id); const data3 = experimentMetricData.find((i) => i.id === data.id); @@ -131,63 +118,42 @@ export class ExperimentRepository extends Repository { }) ); - const experimentInclusionSegmentQuery = this.buildInclusionSegmentQuery().where( + const experimentSegmentQuery = this.buildSegmentQuery().where( new Brackets((qb) => { qb.where(whereExperimentsClause, whereClauseParams); }) ); - const experimentExclusionSegmentQuery = this.buildExclusionSegmentQuery().where( - new Brackets((qb) => { - qb.where(whereExperimentsClause, whereClauseParams); - }) - ); - - const [ - experimentConditionLevelPayloadData, - experimentFactorDecisionPointLevelPayloadData, - experimentInclusionSegmentData, - experimentExclusionSegmentData, - ] = await Promise.all([ - experimentConditionLevelPayloadQuery.getMany().catch((errorMsg: any) => { - const errorMsgString = repositoryError( - 'ExperimentRepository', - 'getValidExperiments-experimentConditionLevelPayloadQuery', - {}, - errorMsg - ); - throw errorMsgString; - }), - experimentFactorDecisionPointLevelPayloadQuery.getMany().catch((errorMsg: any) => { - const errorMsgString = repositoryError( - 'ExperimentRepository', - 'getValidExperiments-experimentFactorDecisionPointLevelPayloadQuery', - {}, - errorMsg - ); - throw errorMsgString; - }), - experimentInclusionSegmentQuery.getMany().catch((errorMsg: any) => { - const errorMsgString = repositoryError( - 'ExperimentRepository', - 'getValidExperiments-experimentInclusionSegmentQuery', - {}, - errorMsg - ); - throw errorMsgString; - }), - experimentExclusionSegmentQuery.getMany().catch((errorMsg: any) => { - const errorMsgString = repositoryError( - 'ExperimentRepository', - 'getValidExperiments-experimentExclusionSegmentQuery', - {}, - errorMsg - ); - throw errorMsgString; - }), - ]); - - const experimentSegmentData = this.mergeSegmentData(experimentInclusionSegmentData, experimentExclusionSegmentData); + const [experimentConditionLevelPayloadData, experimentFactorDecisionPointLevelPayloadData, experimentSegmentData] = + await Promise.all([ + experimentConditionLevelPayloadQuery.getMany().catch((errorMsg: any) => { + const errorMsgString = repositoryError( + 'ExperimentRepository', + 'getValidExperiments-experimentConditionLevelPayloadQuery', + {}, + errorMsg + ); + throw errorMsgString; + }), + experimentFactorDecisionPointLevelPayloadQuery.getMany().catch((errorMsg: any) => { + const errorMsgString = repositoryError( + 'ExperimentRepository', + 'getValidExperiments-experimentFactorDecisionPointLevelPayloadQuery', + {}, + errorMsg + ); + throw errorMsgString; + }), + experimentSegmentQuery.getMany().catch((errorMsg: any) => { + const errorMsgString = repositoryError( + 'ExperimentRepository', + 'getValidExperiments-experimentSegmentQuery', + {}, + errorMsg + ); + throw errorMsgString; + }), + ]); const experimentData = experimentConditionLevelPayloadData.map((data) => { const data2 = experimentFactorDecisionPointLevelPayloadData.find((i) => i.id === data.id); @@ -238,7 +204,7 @@ export class ExperimentRepository extends Repository { }) ); - const inclusionSegmentQuery = this.buildInclusionSegmentQuery() + const segmentQuery = this.buildSegmentQuery() .leftJoin('experiment.partitions', 'partitions') .where( new Brackets((qb) => { @@ -246,55 +212,35 @@ export class ExperimentRepository extends Repository { }) ); - const exclusionSegmentQuery = this.buildExclusionSegmentQuery() - .leftJoin('experiment.partitions', 'partitions') - .where( - new Brackets((qb) => { - qb.where(decisionPointWhereClause, whereClauseParams); - }) - ); - - const [conditionLevelPayloadData, factorDecisionPointPayloadData, inclusionSegmentData, exclusionSegmentData] = - await Promise.all([ - conditionLevelPayloadQuery.getMany().catch((errorMsg: any) => { - const errorMsgString = repositoryError( - 'ExperimentRepository', - 'getValidExperimentsForContextAndDecisionPoint-conditionLevelPayloadData', - {}, - errorMsg - ); - throw errorMsgString; - }), - factorDecisionPointPayloadQuery.getMany().catch((errorMsg: any) => { - const errorMsgString = repositoryError( - 'ExperimentRepository', - 'getValidExperimentsForContextAndDecisionPoint-factorDecisionPointPayloadData', - {}, - errorMsg - ); - throw errorMsgString; - }), - inclusionSegmentQuery.getMany().catch((errorMsg: any) => { - const errorMsgString = repositoryError( - 'ExperimentRepository', - 'getValidExperimentsForContextAndDecisionPoint-inclusionSegmentData', - {}, - errorMsg - ); - throw errorMsgString; - }), - exclusionSegmentQuery.getMany().catch((errorMsg: any) => { - const errorMsgString = repositoryError( - 'ExperimentRepository', - 'getValidExperimentsForContextAndDecisionPoint-exclusionSegmentData', - {}, - errorMsg - ); - throw errorMsgString; - }), - ]); - - const segmentData = this.mergeSegmentData(inclusionSegmentData, exclusionSegmentData); + const [conditionLevelPayloadData, factorDecisionPointPayloadData, segmentData] = await Promise.all([ + conditionLevelPayloadQuery.getMany().catch((errorMsg: any) => { + const errorMsgString = repositoryError( + 'ExperimentRepository', + 'getValidExperimentsForContextAndDecisionPoint-conditionLevelPayloadData', + {}, + errorMsg + ); + throw errorMsgString; + }), + factorDecisionPointPayloadQuery.getMany().catch((errorMsg: any) => { + const errorMsgString = repositoryError( + 'ExperimentRepository', + 'getValidExperimentsForContextAndDecisionPoint-factorDecisionPointPayloadData', + {}, + errorMsg + ); + throw errorMsgString; + }), + segmentQuery.getMany().catch((errorMsg: any) => { + const errorMsgString = repositoryError( + 'ExperimentRepository', + 'getValidExperimentsForContextAndDecisionPoint-segmentData', + {}, + errorMsg + ); + throw errorMsgString; + }), + ]); const experimentData = factorDecisionPointPayloadData.map((data) => { const condData = conditionLevelPayloadData.find((i) => i.id === data.id); @@ -329,63 +275,42 @@ export class ExperimentRepository extends Repository { }) ); - const experimentInclusionSegmentQuery = this.buildInclusionSegmentQuery().where( + const experimentSegmentQuery = this.buildSegmentQuery().where( new Brackets((qb) => { qb.where(whereExperimentsClause, whereClauseParams); }) ); - const experimentExclusionSegmentQuery = this.buildExclusionSegmentQuery().where( - new Brackets((qb) => { - qb.where(whereExperimentsClause, whereClauseParams); - }) - ); - - const [ - experimentConditionLevelPayloadData, - experimentFactorDecisionPointLevelPayloadData, - experimentInclusionSegmentData, - experimentExclusionSegmentData, - ] = await Promise.all([ - experimentConditionLevelPayloadQuery.getMany().catch((errorMsg: any) => { - const errorMsgString = repositoryError( - 'ExperimentRepository', - 'getValidExperimentsWithPreview-experimentConditionLevelPayloadQuery', - {}, - errorMsg - ); - throw errorMsgString; - }), - experimentFactorDecisionPointLevelPayloadQuery.getMany().catch((errorMsg: any) => { - const errorMsgString = repositoryError( - 'ExperimentRepository', - 'getValidExperimentsWithPreview-experimentFactorDecisionPointLevelPayloadQuery', - {}, - errorMsg - ); - throw errorMsgString; - }), - experimentInclusionSegmentQuery.getMany().catch((errorMsg: any) => { - const errorMsgString = repositoryError( - 'ExperimentRepository', - 'getValidExperimentsWithPreview-experimentInclusionSegmentQuery', - {}, - errorMsg - ); - throw errorMsgString; - }), - experimentExclusionSegmentQuery.getMany().catch((errorMsg: any) => { - const errorMsgString = repositoryError( - 'ExperimentRepository', - 'getValidExperimentsWithPreview-experimentExclusionSegmentQuery', - {}, - errorMsg - ); - throw errorMsgString; - }), - ]); - - const experimentSegmentData = this.mergeSegmentData(experimentInclusionSegmentData, experimentExclusionSegmentData); + const [experimentConditionLevelPayloadData, experimentFactorDecisionPointLevelPayloadData, experimentSegmentData] = + await Promise.all([ + experimentConditionLevelPayloadQuery.getMany().catch((errorMsg: any) => { + const errorMsgString = repositoryError( + 'ExperimentRepository', + 'getValidExperimentsWithPreview-experimentConditionLevelPayloadQuery', + {}, + errorMsg + ); + throw errorMsgString; + }), + experimentFactorDecisionPointLevelPayloadQuery.getMany().catch((errorMsg: any) => { + const errorMsgString = repositoryError( + 'ExperimentRepository', + 'getValidExperimentsWithPreview-experimentFactorDecisionPointLevelPayloadQuery', + {}, + errorMsg + ); + throw errorMsgString; + }), + experimentSegmentQuery.getMany().catch((errorMsg: any) => { + const errorMsgString = repositoryError( + 'ExperimentRepository', + 'getValidExperimentsWithPreview-experimentSegmentQuery', + {}, + errorMsg + ); + throw errorMsgString; + }), + ]); const experimentData = experimentConditionLevelPayloadData.map((data) => { const data2 = experimentFactorDecisionPointLevelPayloadData.find((i) => i.id === data.id); @@ -543,19 +468,14 @@ export class ExperimentRepository extends Repository { .leftJoinAndSelect('factors.levels', 'levels'); } - private buildInclusionSegmentQuery() { + private buildSegmentQuery() { return this.createQueryBuilder('experiment') .select('experiment.id') .leftJoinAndSelect('experiment.experimentSegmentInclusion', 'experimentSegmentInclusion') .leftJoinAndSelect('experimentSegmentInclusion.segment', 'segmentInclusion') .leftJoinAndSelect('segmentInclusion.individualForSegment', 'individualForSegment') .leftJoinAndSelect('segmentInclusion.groupForSegment', 'groupForSegment') - .leftJoinAndSelect('segmentInclusion.subSegments', 'subSegment'); - } - - private buildExclusionSegmentQuery() { - return this.createQueryBuilder('experiment') - .select('experiment.id') + .leftJoinAndSelect('segmentInclusion.subSegments', 'subSegment') .leftJoinAndSelect('experiment.experimentSegmentExclusion', 'experimentSegmentExclusion') .leftJoinAndSelect('experimentSegmentExclusion.segment', 'segmentExclusion') .leftJoinAndSelect('segmentExclusion.individualForSegment', 'individualForSegmentExclusion') @@ -563,28 +483,6 @@ export class ExperimentRepository extends Repository { .leftJoinAndSelect('segmentExclusion.subSegments', 'subSegmentExclusion'); } - private mergeSegmentData(inclusionData: Experiment[], exclusionData: Experiment[]): Experiment[] { - const inclusionById = new Map(inclusionData.map((experiment) => [experiment.id, experiment])); - const exclusionById = new Map(exclusionData.map((experiment) => [experiment.id, experiment])); - const experimentIds = new Set([...inclusionById.keys(), ...exclusionById.keys()]); - - return [...experimentIds].map((experimentId) => { - const inclusion = inclusionById.get(experimentId); - const exclusion = exclusionById.get(experimentId); - - return { - ...inclusion, - ...exclusion, - ...(inclusion?.experimentSegmentInclusion !== undefined - ? { experimentSegmentInclusion: inclusion.experimentSegmentInclusion } - : {}), - ...(exclusion?.experimentSegmentExclusion !== undefined - ? { experimentSegmentExclusion: exclusion.experimentSegmentExclusion } - : {}), - } as Experiment; - }); - } - public async findOneExperiment(id: string): Promise { const conditionLevelPayloadQuery = this.buildConditionLevelPayloadQuery() .addOrderBy('conditions.order', 'ASC') @@ -604,67 +502,51 @@ export class ExperimentRepository extends Repository { .addOrderBy('queries.createdAt', 'ASC') .where({ id }); - const inclusionSegmentQuery = this.buildInclusionSegmentQuery().where({ id }); - const exclusionSegmentQuery = this.buildExclusionSegmentQuery().where({ id }); + const segmentQuery = this.buildSegmentQuery().where({ id }); - const [conditionLevelPayloadData, factorDecisionPointPayloadData, metricData, inclusionData, exclusionData] = - await Promise.all([ - conditionLevelPayloadQuery.getOne().catch((errorMsg: any) => { - const errorMsgString = repositoryError( - 'ExperimentRepository', - 'findOneExperiment-conditionLevelPayloadData', - { id }, - errorMsg - ); - throw errorMsgString; - }), - factorDecisionPointPayloadQuery.getOne().catch((errorMsg: any) => { - const errorMsgString = repositoryError( - 'ExperimentRepository', - 'findOneExperiment-factorDecisionPointPayloadData', - { id }, - errorMsg - ); - throw errorMsgString; - }), - metricQuery.getOne().catch((errorMsg: any) => { - const errorMsgString = repositoryError( - 'ExperimentRepository', - 'findOneExperiment-metricData', - { id }, - errorMsg - ); - throw errorMsgString; - }), - inclusionSegmentQuery.getOne().catch((errorMsg: any) => { - const errorMsgString = repositoryError( - 'ExperimentRepository', - 'findOneExperiment-inclusionSegmentData', - { id }, - errorMsg - ); - throw errorMsgString; - }), - exclusionSegmentQuery.getOne().catch((errorMsg: any) => { - const errorMsgString = repositoryError( - 'ExperimentRepository', - 'findOneExperiment-exclusionSegmentData', - { id }, - errorMsg - ); - throw errorMsgString; - }), - ]); + const [conditionLevelPayloadData, factorDecisionPointPayloadData, metricData, segmentData] = await Promise.all([ + conditionLevelPayloadQuery.getOne().catch((errorMsg: any) => { + const errorMsgString = repositoryError( + 'ExperimentRepository', + 'findOneExperiment-conditionLevelPayloadData', + { id }, + errorMsg + ); + throw errorMsgString; + }), + factorDecisionPointPayloadQuery.getOne().catch((errorMsg: any) => { + const errorMsgString = repositoryError( + 'ExperimentRepository', + 'findOneExperiment-factorDecisionPointPayloadData', + { id }, + errorMsg + ); + throw errorMsgString; + }), + metricQuery.getOne().catch((errorMsg: any) => { + const errorMsgString = repositoryError( + 'ExperimentRepository', + 'findOneExperiment-metricData', + { id }, + errorMsg + ); + throw errorMsgString; + }), + segmentQuery.getOne().catch((errorMsg: any) => { + const errorMsgString = repositoryError( + 'ExperimentRepository', + 'findOneExperiment-segmentData', + { id }, + errorMsg + ); + throw errorMsgString; + }), + ]); if (!conditionLevelPayloadData) { return undefined; } - const [segmentData] = this.mergeSegmentData( - inclusionData ? [inclusionData] : [], - exclusionData ? [exclusionData] : [] - ); - return { ...conditionLevelPayloadData, ...factorDecisionPointPayloadData, ...metricData, ...segmentData }; } diff --git a/packages/backend/test/unit/repositories/ExperimentRepository.test.ts b/packages/backend/test/unit/repositories/ExperimentRepository.test.ts index 690bbb91fe..da64077b43 100644 --- a/packages/backend/test/unit/repositories/ExperimentRepository.test.ts +++ b/packages/backend/test/unit/repositories/ExperimentRepository.test.ts @@ -155,11 +155,11 @@ describe('ExperimentRepository Testing', () => { const res = await repo.findAllExperiments(); - expect(repo.createQueryBuilder).toHaveBeenCalledTimes(5); + expect(repo.createQueryBuilder).toHaveBeenCalledTimes(4); expect(mock.leftJoinAndSelect).toHaveBeenCalledTimes(23); - expect(mock.select).toHaveBeenCalledTimes(2); - expect(mock.getMany).toHaveBeenCalledTimes(5); + expect(mock.select).toHaveBeenCalledTimes(1); + expect(mock.getMany).toHaveBeenCalledTimes(4); // queries are ordered by `order` ASC (NULLS LAST) then `createdAt` ASC as a stable fallback expect(mock.addOrderBy).toHaveBeenCalledTimes(2); @@ -169,33 +169,6 @@ describe('ExperimentRepository Testing', () => { expect(res).toEqual(result); }); - it('should merge separately loaded inclusion and exclusion segment data', async () => { - const conditionData = { id: 'exp-a', name: 'Experiment A', conditions: ['condition'] } as any; - const factorData = { id: 'exp-a', partitions: ['partition'] } as any; - const metricData = { id: 'exp-a', queries: ['query'] } as any; - const inclusionData = { id: 'exp-a', experimentSegmentInclusion: ['inclusion'] } as any; - const exclusionData = { id: 'exp-a', experimentSegmentExclusion: ['exclusion'] } as any; - - mock.getMany - .mockResolvedValueOnce([conditionData]) - .mockResolvedValueOnce([factorData]) - .mockResolvedValueOnce([metricData]) - .mockResolvedValueOnce([inclusionData]) - .mockResolvedValueOnce([exclusionData]); - - const [res] = await repo.findAllExperiments(); - - expect(res).toMatchObject({ - id: 'exp-a', - name: 'Experiment A', - conditions: ['condition'], - partitions: ['partition'], - queries: ['query'], - experimentSegmentInclusion: ['inclusion'], - experimentSegmentExclusion: ['exclusion'], - }); - }); - it('should throw an error when find all experiments fails', async () => { mock.getMany.mockRejectedValue(err); @@ -203,11 +176,11 @@ describe('ExperimentRepository Testing', () => { await repo.findAllExperiments(); }).rejects.toThrow(err); - expect(repo.createQueryBuilder).toHaveBeenCalledTimes(5); + expect(repo.createQueryBuilder).toHaveBeenCalledTimes(4); expect(mock.leftJoinAndSelect).toHaveBeenCalledTimes(23); - expect(mock.select).toHaveBeenCalledTimes(2); - expect(mock.getMany).toHaveBeenCalledTimes(5); + expect(mock.select).toHaveBeenCalledTimes(1); + expect(mock.getMany).toHaveBeenCalledTimes(4); }); it('should find all experiments by name', async () => { @@ -240,12 +213,12 @@ describe('ExperimentRepository Testing', () => { const res = await repo.getValidExperiments('context'); - expect(repo.createQueryBuilder).toHaveBeenCalledTimes(4); + expect(repo.createQueryBuilder).toHaveBeenCalledTimes(3); expect(mock.leftJoinAndSelect).toHaveBeenCalledTimes(20); - expect(mock.where).toHaveBeenCalledTimes(4); - expect(mock.select).toHaveBeenCalledTimes(2); - expect(mock.getMany).toHaveBeenCalledTimes(4); + expect(mock.where).toHaveBeenCalledTimes(3); + expect(mock.select).toHaveBeenCalledTimes(1); + expect(mock.getMany).toHaveBeenCalledTimes(3); expect(res).toEqual(result); }); @@ -257,12 +230,12 @@ describe('ExperimentRepository Testing', () => { await repo.getValidExperiments('context'); }).rejects.toThrow(err); - expect(repo.createQueryBuilder).toHaveBeenCalledTimes(4); + expect(repo.createQueryBuilder).toHaveBeenCalledTimes(3); expect(mock.leftJoinAndSelect).toHaveBeenCalledTimes(20); - expect(mock.where).toHaveBeenCalledTimes(4); - expect(mock.select).toHaveBeenCalledTimes(2); - expect(mock.getMany).toHaveBeenCalledTimes(4); + expect(mock.where).toHaveBeenCalledTimes(3); + expect(mock.select).toHaveBeenCalledTimes(1); + expect(mock.getMany).toHaveBeenCalledTimes(3); }); it('should get valid experiments with preview', async () => { @@ -271,12 +244,12 @@ describe('ExperimentRepository Testing', () => { const res = await repo.getValidExperimentsWithPreview('context'); - expect(repo.createQueryBuilder).toHaveBeenCalledTimes(4); + expect(repo.createQueryBuilder).toHaveBeenCalledTimes(3); expect(mock.leftJoinAndSelect).toHaveBeenCalledTimes(20); - expect(mock.where).toHaveBeenCalledTimes(4); - expect(mock.select).toHaveBeenCalledTimes(2); - expect(mock.getMany).toHaveBeenCalledTimes(4); + expect(mock.where).toHaveBeenCalledTimes(3); + expect(mock.select).toHaveBeenCalledTimes(1); + expect(mock.getMany).toHaveBeenCalledTimes(3); expect(res).toEqual(result); }); @@ -288,12 +261,12 @@ describe('ExperimentRepository Testing', () => { await repo.getValidExperimentsWithPreview('context'); }).rejects.toThrow(err); - expect(repo.createQueryBuilder).toHaveBeenCalledTimes(4); + expect(repo.createQueryBuilder).toHaveBeenCalledTimes(3); expect(mock.leftJoinAndSelect).toHaveBeenCalledTimes(20); - expect(mock.where).toHaveBeenCalledTimes(4); - expect(mock.select).toHaveBeenCalledTimes(2); - expect(mock.getMany).toHaveBeenCalledTimes(4); + expect(mock.where).toHaveBeenCalledTimes(3); + expect(mock.select).toHaveBeenCalledTimes(1); + expect(mock.getMany).toHaveBeenCalledTimes(3); }); it('should update experiment state', async () => { @@ -396,47 +369,20 @@ describe('ExperimentRepository Testing', () => { it('should find one experiment ordered by queries.order then createdAt', async () => { const res = await repo.findOneExperiment(experiment.id); - // 5 parallel queries: conditionLevelPayload, factorDecisionPointPayload, metric, inclusion, exclusion - expect(repo.createQueryBuilder).toHaveBeenCalledTimes(5); + // 4 parallel queries: conditionLevelPayload, factorDecisionPointPayload, metric, segment + expect(repo.createQueryBuilder).toHaveBeenCalledTimes(4); // conditions(1) + partitions+factors+levels(3) + queries.order+createdAt(2) = 6 addOrderBy calls expect(mock.addOrderBy).toHaveBeenCalledTimes(6); expect(mock.addOrderBy).toHaveBeenCalledWith('queries.order', 'ASC', 'NULLS LAST'); expect(mock.addOrderBy).toHaveBeenCalledWith('queries.createdAt', 'ASC'); - expect(mock.where).toHaveBeenCalledTimes(5); - expect(mock.getOne).toHaveBeenCalledTimes(5); + expect(mock.where).toHaveBeenCalledTimes(4); + expect(mock.getOne).toHaveBeenCalledTimes(4); expect(res).toEqual(experiment); }); - it('should merge separately loaded segment data when finding one experiment', async () => { - const conditionData = { id: 'exp-a', name: 'Experiment A', conditions: ['condition'] } as any; - const factorData = { id: 'exp-a', partitions: ['partition'] } as any; - const metricData = { id: 'exp-a', queries: ['query'] } as any; - const inclusionData = { id: 'exp-a', experimentSegmentInclusion: ['inclusion'] } as any; - const exclusionData = { id: 'exp-a', experimentSegmentExclusion: ['exclusion'] } as any; - - mock.getOne - .mockResolvedValueOnce(conditionData) - .mockResolvedValueOnce(factorData) - .mockResolvedValueOnce(metricData) - .mockResolvedValueOnce(inclusionData) - .mockResolvedValueOnce(exclusionData); - - const res = await repo.findOneExperiment('exp-a'); - - expect(res).toMatchObject({ - id: 'exp-a', - name: 'Experiment A', - conditions: ['condition'], - partitions: ['partition'], - queries: ['query'], - experimentSegmentInclusion: ['inclusion'], - experimentSegmentExclusion: ['exclusion'], - }); - }); - it('should clear the database', async () => { const entities = [ { @@ -466,33 +412,29 @@ describe('ExperimentRepository Testing', () => { }); describe('getValidExperimentsForContextAndDecisionPoint', () => { - it('should build four queries and add a leftJoin on partitions for the condition and segment queries', async () => { + it('should build three queries and add a leftJoin on partitions (decision points) for the condition and segment queries', async () => { const result = [experiment]; mock.getMany.mockResolvedValue(result); const res = await repo.getValidExperimentsForContextAndDecisionPoint('context', 'site1', 'target1'); - expect(repo.createQueryBuilder).toHaveBeenCalledTimes(4); - // 4 (conditionLevel) + 6 (factorDecisionPoint) + 5 (inclusion) + 5 (exclusion) = 20 + expect(repo.createQueryBuilder).toHaveBeenCalledTimes(3); + // 4 (conditionLevel) + 6 (factorDecisionPoint) + 10 (segment) = 20 expect(mock.leftJoinAndSelect).toHaveBeenCalledTimes(20); - // conditionLevelPayloadQuery and both segment queries add a non-selecting leftJoin for partition filtering - expect(mock.leftJoin).toHaveBeenCalledTimes(3); + // conditionLevelPayloadQuery and segmentQuery each add a non-selecting leftJoin for partition filtering + expect(mock.leftJoin).toHaveBeenCalledTimes(2); expect(mock.leftJoin).toHaveBeenCalledWith('experiment.partitions', 'partitions'); - // Both segment queries call .select('experiment.id') - expect(mock.select).toHaveBeenCalledTimes(2); - expect(mock.where).toHaveBeenCalledTimes(4); - expect(mock.getMany).toHaveBeenCalledTimes(4); + // buildSegmentQuery calls .select('experiment.id') + expect(mock.select).toHaveBeenCalledTimes(1); + expect(mock.where).toHaveBeenCalledTimes(3); + expect(mock.getMany).toHaveBeenCalledTimes(3); expect(res).toEqual(result); }); it('should return empty array when no experiments match the site/target', async () => { // conditionLevel and segment find experiments, but factorDecisionPoint finds none at this site/target - mock.getMany - .mockResolvedValueOnce([experiment]) - .mockResolvedValueOnce([]) - .mockResolvedValueOnce([experiment]) - .mockResolvedValueOnce([experiment]); + mock.getMany.mockResolvedValueOnce([experiment]).mockResolvedValueOnce([]).mockResolvedValueOnce([experiment]); const res = await repo.getValidExperimentsForContextAndDecisionPoint('context', 'site1', 'target1'); @@ -509,7 +451,6 @@ describe('ExperimentRepository Testing', () => { mock.getMany .mockResolvedValueOnce([expA, expB]) .mockResolvedValueOnce([expA]) - .mockResolvedValueOnce([expA, expB]) .mockResolvedValueOnce([expA, expB]); const res = await repo.getValidExperimentsForContextAndDecisionPoint('context', 'site1', 'target1'); @@ -521,14 +462,12 @@ describe('ExperimentRepository Testing', () => { it('should merge condition, partition, and segment data onto each result experiment', async () => { const condData = { id: 'exp-a', conditions: ['cond1'] } as any; const factorData = { id: 'exp-a', partitions: ['part1'] } as any; - const inclusionData = { id: 'exp-a', experimentSegmentInclusion: ['seg1'] } as any; - const exclusionData = { id: 'exp-a', experimentSegmentExclusion: ['seg2'] } as any; + const segData = { id: 'exp-a', experimentSegmentInclusion: ['seg1'] } as any; mock.getMany .mockResolvedValueOnce([condData]) .mockResolvedValueOnce([factorData]) - .mockResolvedValueOnce([inclusionData]) - .mockResolvedValueOnce([exclusionData]); + .mockResolvedValueOnce([segData]); const [result] = await repo.getValidExperimentsForContextAndDecisionPoint('context', 'site1', 'target1'); @@ -537,7 +476,6 @@ describe('ExperimentRepository Testing', () => { conditions: ['cond1'], partitions: ['part1'], experimentSegmentInclusion: ['seg1'], - experimentSegmentExclusion: ['seg2'], }); }); @@ -545,11 +483,7 @@ describe('ExperimentRepository Testing', () => { const condData = { id: 'exp-a', conditions: ['cond1'] } as any; const factorData = { id: 'exp-a', partitions: ['part1'] } as any; - mock.getMany - .mockResolvedValueOnce([condData]) - .mockResolvedValueOnce([factorData]) - .mockResolvedValueOnce([]) - .mockResolvedValueOnce([]); + mock.getMany.mockResolvedValueOnce([condData]).mockResolvedValueOnce([factorData]).mockResolvedValueOnce([]); const [result] = await repo.getValidExperimentsForContextAndDecisionPoint('context', 'site1', 'target1'); @@ -558,23 +492,13 @@ describe('ExperimentRepository Testing', () => { it('should return factorDecisionPoint data even when conditionLevel query returns no match', async () => { const factorData = { id: 'exp-a', partitions: ['part1'] } as any; - const inclusionData = { id: 'exp-a', experimentSegmentInclusion: ['seg1'] } as any; - const exclusionData = { id: 'exp-a', experimentSegmentExclusion: ['seg2'] } as any; + const segData = { id: 'exp-a', experimentSegmentInclusion: ['seg1'] } as any; - mock.getMany - .mockResolvedValueOnce([]) - .mockResolvedValueOnce([factorData]) - .mockResolvedValueOnce([inclusionData]) - .mockResolvedValueOnce([exclusionData]); + mock.getMany.mockResolvedValueOnce([]).mockResolvedValueOnce([factorData]).mockResolvedValueOnce([segData]); const [result] = await repo.getValidExperimentsForContextAndDecisionPoint('context', 'site1', 'target1'); - expect(result).toMatchObject({ - id: 'exp-a', - partitions: ['part1'], - experimentSegmentInclusion: ['seg1'], - experimentSegmentExclusion: ['seg2'], - }); + expect(result).toMatchObject({ id: 'exp-a', partitions: ['part1'], experimentSegmentInclusion: ['seg1'] }); }); it('should throw an error when a sub-query fails', async () => { @@ -582,7 +506,7 @@ describe('ExperimentRepository Testing', () => { await expect(repo.getValidExperimentsForContextAndDecisionPoint('context', 'site1', 'target1')).rejects.toThrow(); - expect(repo.createQueryBuilder).toHaveBeenCalledTimes(4); + expect(repo.createQueryBuilder).toHaveBeenCalledTimes(3); }); }); From 58103670cf0bf1834fb3783dbf68024937372cf6 Mon Sep 17 00:00:00 2001 From: Zack Lee Date: Fri, 21 Aug 2026 22:28:27 -0400 Subject: [PATCH 16/33] fix: reject invalid list filter modes --- .../core/segments/list-details.utils.spec.ts | 19 +++++++++++++++++++ .../app/core/segments/list-details.utils.ts | 15 +++++++++++++++ .../list-details-page.component.ts | 15 ++++++++++----- 3 files changed, 44 insertions(+), 5 deletions(-) create mode 100644 packages/frontend/projects/upgrade/src/app/core/segments/list-details.utils.spec.ts create mode 100644 packages/frontend/projects/upgrade/src/app/core/segments/list-details.utils.ts diff --git a/packages/frontend/projects/upgrade/src/app/core/segments/list-details.utils.spec.ts b/packages/frontend/projects/upgrade/src/app/core/segments/list-details.utils.spec.ts new file mode 100644 index 0000000000..9a6bd68a44 --- /dev/null +++ b/packages/frontend/projects/upgrade/src/app/core/segments/list-details.utils.spec.ts @@ -0,0 +1,19 @@ +import { LIST_FILTER_MODE } from 'upgrade_types'; +import { parseListFilterMode } from './list-details.utils'; + +describe('list details utils', () => { + describe('#parseListFilterMode', () => { + it.each([ + ['inclusion', LIST_FILTER_MODE.INCLUSION], + ['INCLUSION', LIST_FILTER_MODE.INCLUSION], + ['exclusion', LIST_FILTER_MODE.EXCLUSION], + ['EXCLUSION', LIST_FILTER_MODE.EXCLUSION], + ])('should parse %s', (filterMode, expected) => { + expect(parseListFilterMode(filterMode)).toBe(expected); + }); + + it.each([undefined, null, '', 'typo'])('should reject %s', (filterMode) => { + expect(parseListFilterMode(filterMode)).toBeUndefined(); + }); + }); +}); diff --git a/packages/frontend/projects/upgrade/src/app/core/segments/list-details.utils.ts b/packages/frontend/projects/upgrade/src/app/core/segments/list-details.utils.ts new file mode 100644 index 0000000000..d40c253be2 --- /dev/null +++ b/packages/frontend/projects/upgrade/src/app/core/segments/list-details.utils.ts @@ -0,0 +1,15 @@ +import { LIST_FILTER_MODE } from 'upgrade_types'; + +export function parseListFilterMode(filterMode: string | null | undefined): LIST_FILTER_MODE | undefined { + const normalizedFilterMode = filterMode?.toLowerCase(); + + if (normalizedFilterMode === LIST_FILTER_MODE.INCLUSION) { + return LIST_FILTER_MODE.INCLUSION; + } + + if (normalizedFilterMode === LIST_FILTER_MODE.EXCLUSION) { + return LIST_FILTER_MODE.EXCLUSION; + } + + return undefined; +} diff --git a/packages/frontend/projects/upgrade/src/app/features/dashboard/segments/pages/list-details-page/list-details-page.component.ts b/packages/frontend/projects/upgrade/src/app/features/dashboard/segments/pages/list-details-page/list-details-page.component.ts index 5c1fc28bdc..53660951bf 100644 --- a/packages/frontend/projects/upgrade/src/app/features/dashboard/segments/pages/list-details-page/list-details-page.component.ts +++ b/packages/frontend/projects/upgrade/src/app/features/dashboard/segments/pages/list-details-page/list-details-page.component.ts @@ -23,6 +23,7 @@ import { IMenuButtonItem, LIST_FILTER_MODE, SEGMENT_TYPE } from 'upgrade_types'; import { AuthService } from '../../../../../core/auth/auth.service'; import { NotificationService } from '../../../../../core/core.module'; import { ListDetailsDataService } from '../../../../../core/segments/list-details.data.service'; +import { parseListFilterMode } from '../../../../../core/segments/list-details.utils'; import { EditPrivateSegmentListDetails, LIST_OPTION_TYPE, @@ -85,7 +86,7 @@ export class ListDetailsPageComponent implements OnInit, OnDestroy { ownerType: LIST_OWNER_TYPE; ownerId = ''; listId = ''; - filterMode = LIST_FILTER_MODE.EXCLUSION; + filterMode: LIST_FILTER_MODE; owner: ListDetailsOwner; list: Segment; listType = ''; @@ -124,10 +125,14 @@ export class ListDetailsPageComponent implements OnInit, OnDestroy { this.ownerType = this.route.snapshot.data['listOwnerType']; this.ownerId = this.getOwnerId(); this.listId = this.route.snapshot.paramMap.get('listId') ?? ''; - this.filterMode = - this.route.snapshot.paramMap.get('filterMode')?.toLowerCase() === LIST_FILTER_MODE.INCLUSION - ? LIST_FILTER_MODE.INCLUSION - : LIST_FILTER_MODE.EXCLUSION; + const filterMode = parseListFilterMode(this.route.snapshot.paramMap.get('filterMode')); + if (!filterMode) { + this.isLoading = false; + this.notificationService.showError('Unable to load list details.'); + this.router.navigate(this.parentLink); + return; + } + this.filterMode = filterMode; this.subscriptions.add( // List management is gated on segment permissions across all owner pages (see the From 3db1d058589c5da125a5cc007760c8df3bce78ae Mon Sep 17 00:00:00 2001 From: Zack Lee Date: Fri, 21 Aug 2026 23:23:15 -0400 Subject: [PATCH 17/33] fix: cancel stale CSV file reads --- .../upsert-list-values-modal.component.ts | 44 +++++++++++++++++-- 1 file changed, 40 insertions(+), 4 deletions(-) diff --git a/packages/frontend/projects/upgrade/src/app/features/dashboard/segments/modals/upsert-list-values-modal/upsert-list-values-modal.component.ts b/packages/frontend/projects/upgrade/src/app/features/dashboard/segments/modals/upsert-list-values-modal/upsert-list-values-modal.component.ts index 3bf632f095..25c86414f6 100644 --- a/packages/frontend/projects/upgrade/src/app/features/dashboard/segments/modals/upsert-list-values-modal/upsert-list-values-modal.component.ts +++ b/packages/frontend/projects/upgrade/src/app/features/dashboard/segments/modals/upsert-list-values-modal/upsert-list-values-modal.component.ts @@ -1,4 +1,4 @@ -import { ChangeDetectionStrategy, ChangeDetectorRef, Component, Inject } from '@angular/core'; +import { ChangeDetectionStrategy, ChangeDetectorRef, Component, Inject, OnDestroy } from '@angular/core'; import { CommonModule } from '@angular/common'; import { FormsModule } from '@angular/forms'; import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; @@ -43,7 +43,7 @@ export interface UpsertListValuesModalResult { styleUrl: './upsert-list-values-modal.component.scss', changeDetection: ChangeDetectionStrategy.OnPush, }) -export class UpsertListValuesModalComponent { +export class UpsertListValuesModalComponent implements OnDestroy { rawValues = ''; importedValues: string[] = []; fileName = ''; @@ -51,6 +51,7 @@ export class UpsertListValuesModalComponent { updateMode = LIST_VALUES_UPDATE_MODE.APPEND; readonly UPDATE_MODE = LIST_VALUES_UPDATE_MODE; readonly FILE_TYPE = FILE_TYPE; + private activeFileReader?: FileReader; constructor( @Inject(MAT_DIALOG_DATA) public data: UpsertListValuesModalData, @@ -71,10 +72,12 @@ export class UpsertListValuesModalComponent { } get isPrimaryActionDisabled(): boolean { - return this.values.length === 0 || !!this.errorMessage; + return this.values.length === 0 || !!this.errorMessage || (this.data.importOnly && !this.fileName); } onFilesSelected(files: File[]): void { + this.cancelActiveFileRead(); + const file = files[0]; this.errorMessage = ''; this.importedValues = []; @@ -85,15 +88,27 @@ export class UpsertListValuesModalComponent { } const reader = new FileReader(); + this.activeFileReader = reader; reader.onload = () => { + if (this.activeFileReader !== reader) { + return; + } + try { this.importedValues = parseSingleColumnCSV(String(reader.result ?? '')); } catch (error) { this.errorMessage = error instanceof Error ? error.message : 'Unable to read CSV file'; + } finally { + this.activeFileReader = undefined; + this.changeDetectorRef.markForCheck(); } - this.changeDetectorRef.markForCheck(); }; reader.onerror = () => { + if (this.activeFileReader !== reader) { + return; + } + + this.activeFileReader = undefined; this.errorMessage = 'Unable to read CSV file'; this.changeDetectorRef.markForCheck(); }; @@ -101,11 +116,16 @@ export class UpsertListValuesModalComponent { } clearImportedFile(): void { + this.cancelActiveFileRead(); this.fileName = ''; this.importedValues = []; this.errorMessage = ''; } + ngOnDestroy(): void { + this.cancelActiveFileRead(); + } + submit(): void { if (this.isPrimaryActionDisabled) { return; @@ -113,4 +133,20 @@ export class UpsertListValuesModalComponent { this.dialogRef.close({ values: this.values, mode: this.updateMode, fileName: this.fileName }); } + + private cancelActiveFileRead(): void { + const reader = this.activeFileReader; + this.activeFileReader = undefined; + + if (!reader) { + return; + } + + reader.onload = null; + reader.onerror = null; + + if (reader.readyState === FileReader.LOADING) { + reader.abort(); + } + } } From 70fedfaa8a6e3ede0ef3bc9e5e59a9712cf4ddae Mon Sep 17 00:00:00 2001 From: Zack Lee Date: Sat, 22 Aug 2026 10:50:27 -0400 Subject: [PATCH 18/33] fix: restore existing CSV export behavior --- .../core/segments/list-values.utils.spec.ts | 57 ++----------------- .../services/common-export-helpers.service.ts | 10 +--- 2 files changed, 6 insertions(+), 61 deletions(-) diff --git a/packages/frontend/projects/upgrade/src/app/core/segments/list-values.utils.spec.ts b/packages/frontend/projects/upgrade/src/app/core/segments/list-values.utils.spec.ts index 7d0a57069d..6368b4984b 100644 --- a/packages/frontend/projects/upgrade/src/app/core/segments/list-values.utils.spec.ts +++ b/packages/frontend/projects/upgrade/src/app/core/segments/list-values.utils.spec.ts @@ -4,41 +4,8 @@ import { parseSingleColumnCSV, splitListValues, } from './list-values.utils'; -import { serializeValuesAsCSV } from '../../shared/services/common-export-helpers.service'; describe('list values utilities', () => { - describe('serializeValuesAsCSV', () => { - it('applies CSV quoting without changing list values', () => { - expect( - serializeValuesAsCSV([ - 'plain', - 'say "hello"', - '=SUM(A1:A2)', - '+cmd', - '-1+2', - '@SUM(A1:A2)', - '=SUM(A1:A2)', - "'=SUM(A1:A2)", - "''=SUM(A1:A2)", - "'school", - ]) - ).toBe( - [ - '"plain"', - '"say ""hello"""', - '"=SUM(A1:A2)"', - '"+cmd"', - '"-1+2"', - '"@SUM(A1:A2)"', - '"=SUM(A1:A2)"', - '"\'=SUM(A1:A2)"', - '"\'\'=SUM(A1:A2)"', - '"\'school"', - ].join('\r\n') - ); - }); - }); - describe('splitListValues', () => { it('splits pasted values on commas, tabs, and new lines', () => { expect(splitListValues('one, two\tthree\nfour\r\nfive')).toEqual(['one', 'two', 'three', 'four', 'five']); @@ -85,8 +52,11 @@ describe('list values utilities', () => { }); }); - it('parses quoted values and escaped quotes', () => { - expect(parseSingleColumnCSV('one\n"say ""hello"""')).toEqual(['one', 'say "hello"']); + it('parses quoted IDs and escaped quotes', () => { + expect(parseSingleColumnCSV('"06df769b-740e-47f6-8548-2a52be1ab4be"\n"say ""hello"""')).toEqual([ + '06df769b-740e-47f6-8548-2a52be1ab4be', + 'say "hello"', + ]); }); it('rejects separators inside quoted values', () => { @@ -101,23 +71,6 @@ describe('list values utilities', () => { ); }); - it('round-trips values produced by the CSV exporter', () => { - const values = [ - 'plain', - 'say "hello"', - '=SUM(A1:A2)', - '+cmd', - '-1+2', - '@SUM(A1:A2)', - '=SUM(A1:A2)', - "'=SUM(A1:A2)", - "''=SUM(A1:A2)", - "'school", - ]; - - expect(parseSingleColumnCSV(serializeValuesAsCSV(values))).toEqual(values); - }); - it('preserves formula-like prefixes and leading apostrophes', () => { expect( parseSingleColumnCSV("=SUM(A1:A2)\n+cmd\n-1+2\n@SUM(A1:A2)\n'=SUM(A1:A2)\n''=SUM(A1:A2)\n'school") diff --git a/packages/frontend/projects/upgrade/src/app/shared/services/common-export-helpers.service.ts b/packages/frontend/projects/upgrade/src/app/shared/services/common-export-helpers.service.ts index 6316a62ea6..cb6caa67e0 100644 --- a/packages/frontend/projects/upgrade/src/app/shared/services/common-export-helpers.service.ts +++ b/packages/frontend/projects/upgrade/src/app/shared/services/common-export-helpers.service.ts @@ -1,14 +1,6 @@ import { Injectable } from '@angular/core'; import JSZip from 'jszip'; -function escapeCSVField(value: string): string { - return `"${value.replace(/"/g, '""')}"`; -} - -export function serializeValuesAsCSV(values: string[]): string { - return values.map(escapeCSVField).join('\r\n'); -} - @Injectable({ providedIn: 'root', }) @@ -42,7 +34,7 @@ export class CommonExportHelpersService { } downloadValuesAsCSV(values: string[], fileName: string): void { - const csvContent = serializeValuesAsCSV(values); + const csvContent = values.join('\n'); const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' }); const link = document.createElement('a'); if (link.download !== undefined) { From a94f94c6c346992bae5ca87eb036334be2b61a46 Mon Sep 17 00:00:00 2001 From: Zack Lee Date: Sat, 22 Aug 2026 16:37:50 -0400 Subject: [PATCH 19/33] fix: align list permissions with owner pages --- .../list-details-page/list-details-page.component.ts | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/packages/frontend/projects/upgrade/src/app/features/dashboard/segments/pages/list-details-page/list-details-page.component.ts b/packages/frontend/projects/upgrade/src/app/features/dashboard/segments/pages/list-details-page/list-details-page.component.ts index 53660951bf..2ace89a77a 100644 --- a/packages/frontend/projects/upgrade/src/app/features/dashboard/segments/pages/list-details-page/list-details-page.component.ts +++ b/packages/frontend/projects/upgrade/src/app/features/dashboard/segments/pages/list-details-page/list-details-page.component.ts @@ -104,7 +104,6 @@ export class ListDetailsPageComponent implements OnInit, OnDestroy { isValuesSectionExpanded = true; private hasUpdatePermission = false; - private hasDeletePermission = false; private subscriptions = new Subscription(); constructor( @@ -139,7 +138,6 @@ export class ListDetailsPageComponent implements OnInit, OnDestroy { // inclusion/exclusion/lists section cards), so this page must match. this.authService.userPermissions$.subscribe((permissions) => { this.hasUpdatePermission = !!permissions?.segments?.update; - this.hasDeletePermission = !!permissions?.segments?.delete; this.updateMetadataMenuButtonItems(); this.updateValuesMenuButtonItems(); this.changeDetectorRef.markForCheck(); @@ -202,10 +200,6 @@ export class ListDetailsPageComponent implements OnInit, OnDestroy { return this.hasUpdatePermission && !this.isOwnerReadOnly; } - get canDelete(): boolean { - return this.hasDeletePermission && !this.isOwnerReadOnly; - } - private get isPlainSegmentList(): boolean { return this.ownerType === LIST_OWNER_TYPE.SEGMENT && this.owner?.segmentType !== SEGMENT_TYPE.GLOBAL_EXCLUDE; } @@ -432,7 +426,7 @@ export class ListDetailsPageComponent implements OnInit, OnDestroy { }, { action: LIST_DETAILS_ACTION.DELETE, - disabled: !this.canDelete, + disabled: !this.canManage, label: `Delete ${actionTarget}`, }, ]; From f6eeb10af6b496477fa990613856230cc430b122 Mon Sep 17 00:00:00 2001 From: Zack Lee Date: Sat, 22 Aug 2026 18:04:43 -0400 Subject: [PATCH 20/33] fix: report list details load errors consistently --- .../list-details-page.component.ts | 24 +++++++++++-------- 1 file changed, 14 insertions(+), 10 deletions(-) diff --git a/packages/frontend/projects/upgrade/src/app/features/dashboard/segments/pages/list-details-page/list-details-page.component.ts b/packages/frontend/projects/upgrade/src/app/features/dashboard/segments/pages/list-details-page/list-details-page.component.ts index 2ace89a77a..5432346706 100644 --- a/packages/frontend/projects/upgrade/src/app/features/dashboard/segments/pages/list-details-page/list-details-page.component.ts +++ b/packages/frontend/projects/upgrade/src/app/features/dashboard/segments/pages/list-details-page/list-details-page.component.ts @@ -1,5 +1,5 @@ import { CommonModule } from '@angular/common'; -import { ChangeDetectionStrategy, ChangeDetectorRef, Component, OnDestroy, OnInit } from '@angular/core'; +import { ChangeDetectionStrategy, ChangeDetectorRef, Component, ErrorHandler, OnDestroy, OnInit } from '@angular/core'; import { MatButtonModule } from '@angular/material/button'; import { MatDialog } from '@angular/material/dialog'; import { MatIconModule } from '@angular/material/icon'; @@ -115,7 +115,8 @@ export class ListDetailsPageComponent implements OnInit, OnDestroy { private authService: AuthService, private notificationService: NotificationService, private commonExportHelpersService: CommonExportHelpersService, - private changeDetectorRef: ChangeDetectorRef + private changeDetectorRef: ChangeDetectorRef, + private errorHandler: ErrorHandler ) { this.dataSource.filterPredicate = (row, filter) => row.value.toLowerCase().includes(filter); } @@ -124,11 +125,10 @@ export class ListDetailsPageComponent implements OnInit, OnDestroy { this.ownerType = this.route.snapshot.data['listOwnerType']; this.ownerId = this.getOwnerId(); this.listId = this.route.snapshot.paramMap.get('listId') ?? ''; - const filterMode = parseListFilterMode(this.route.snapshot.paramMap.get('filterMode')); + const rawFilterMode = this.route.snapshot.paramMap.get('filterMode'); + const filterMode = parseListFilterMode(rawFilterMode); if (!filterMode) { - this.isLoading = false; - this.notificationService.showError('Unable to load list details.'); - this.router.navigate(this.parentLink); + this.handleLoadError(new Error(`Invalid list filter mode: ${rawFilterMode ?? ''}`)); return; } this.filterMode = filterMode; @@ -206,6 +206,7 @@ export class ListDetailsPageComponent implements OnInit, OnDestroy { loadDetails(): void { if (!this.ownerId || !this.listId) { + this.handleLoadError(new Error('List owner ID and list ID are required.')); return; } @@ -231,14 +232,17 @@ export class ListDetailsPageComponent implements OnInit, OnDestroy { this.updateMetadataMenuButtonItems(); this.changeDetectorRef.markForCheck(); }, - error: () => { - this.notificationService.showError('Unable to load list details.'); - this.changeDetectorRef.markForCheck(); - }, + error: (error) => this.handleLoadError(error), }) ); } + private handleLoadError(error: unknown): void { + this.isLoading = false; + this.changeDetectorRef.markForCheck(); + this.errorHandler.handleError(error); + } + search(searchParams: CommonSearchWidgetSearchParams): void { this.valuesSearchString = searchParams.searchString; this.dataSource.filter = this.valuesSearchString.trim().toLowerCase(); From f776a6729203db04a46461bdb6ce15c901452ac0 Mon Sep 17 00:00:00 2001 From: Zack Lee Date: Sat, 22 Aug 2026 19:48:51 -0400 Subject: [PATCH 21/33] fix: reject segment-backed list detail routes --- .../list-details.data.service.spec.ts | 97 +++++++++++++++++++ .../segments/list-details.data.service.ts | 23 ++++- 2 files changed, 116 insertions(+), 4 deletions(-) diff --git a/packages/frontend/projects/upgrade/src/app/core/segments/list-details.data.service.spec.ts b/packages/frontend/projects/upgrade/src/app/core/segments/list-details.data.service.spec.ts index aa88d36721..c7eb7a08f5 100644 --- a/packages/frontend/projects/upgrade/src/app/core/segments/list-details.data.service.spec.ts +++ b/packages/frontend/projects/upgrade/src/app/core/segments/list-details.data.service.spec.ts @@ -21,6 +21,18 @@ describe('ListDetailsDataService', () => { listType: 'Individual', } as Segment; + const referencedSegment = { + id: 'referenced-segment-id', + name: 'Referenced segment', + type: SEGMENT_TYPE.PUBLIC, + } as Segment; + + const segmentBackedList = { + ...segment, + listType: 'Segment', + subSegments: [referencedSegment], + } as Segment; + const segmentRequest: EditPrivateSegmentListDetails = { id: segment.id, name: segment.name, @@ -189,6 +201,91 @@ describe('ListDetailsDataService', () => { ).rejects.toThrow(`List ${segment.id} does not belong to owner parent-id for ${LIST_FILTER_MODE.INCLUSION}.`); }); + it('rejects a Segment-backed experiment list before loading its members', async () => { + experimentDataService.getExperimentById.mockReturnValue( + of({ + id: 'experiment-id', + name: 'Test experiment', + state: EXPERIMENT_STATE.ENROLLING, + experimentSegmentInclusion: [{ segment: segmentBackedList }], + experimentSegmentExclusion: [], + }) + ); + + await expect( + firstValueFrom( + service.fetchListDetails( + LIST_OWNER_TYPE.EXPERIMENT, + 'experiment-id', + LIST_FILTER_MODE.INCLUSION, + segmentBackedList.id + ) + ) + ).rejects.toThrow(`Segment-backed list ${segmentBackedList.id} cannot be opened in List Details.`); + expect(segmentsDataService.fetchSegmentWithMembersById).not.toHaveBeenCalled(); + }); + + it('rejects a Segment-backed feature flag list before loading its members', async () => { + featureFlagsDataService.fetchFeatureFlagById.mockReturnValue( + of({ + id: 'flag-id', + name: 'Test flag', + featureFlagSegmentInclusion: [{ segment: segmentBackedList, enabled: true, listType: 'Segment' }], + featureFlagSegmentExclusion: [], + }) + ); + + await expect( + firstValueFrom( + service.fetchListDetails( + LIST_OWNER_TYPE.FEATURE_FLAG, + 'flag-id', + LIST_FILTER_MODE.INCLUSION, + segmentBackedList.id + ) + ) + ).rejects.toThrow(`Segment-backed list ${segmentBackedList.id} cannot be opened in List Details.`); + expect(segmentsDataService.fetchSegmentWithMembersById).not.toHaveBeenCalled(); + }); + + it('rejects a Segment-backed nested list before loading its members', async () => { + segmentsDataService.getSegmentById.mockReturnValue( + of({ segment: { id: 'parent-id', name: 'Parent segment', subSegments: [segmentBackedList] } }) + ); + + await expect( + firstValueFrom( + service.fetchListDetails(LIST_OWNER_TYPE.SEGMENT, 'parent-id', LIST_FILTER_MODE.EXCLUSION, segmentBackedList.id) + ) + ).rejects.toThrow(`Segment-backed list ${segmentBackedList.id} cannot be opened in List Details.`); + expect(segmentsDataService.fetchSegmentWithMembersById).not.toHaveBeenCalled(); + }); + + it('rejects a legacy Segment-backed list inferred from its subsegment relationship', async () => { + const legacySegmentBackedList = { ...segmentBackedList, listType: undefined }; + experimentDataService.getExperimentById.mockReturnValue( + of({ + id: 'experiment-id', + name: 'Test experiment', + state: EXPERIMENT_STATE.ENROLLING, + experimentSegmentInclusion: [{ segment: legacySegmentBackedList }], + experimentSegmentExclusion: [], + }) + ); + segmentsDataService.fetchSegmentWithMembersById.mockReturnValue(of(legacySegmentBackedList)); + + await expect( + firstValueFrom( + service.fetchListDetails( + LIST_OWNER_TYPE.EXPERIMENT, + 'experiment-id', + LIST_FILTER_MODE.INCLUSION, + legacySegmentBackedList.id + ) + ) + ).rejects.toThrow(`Segment-backed list ${legacySegmentBackedList.id} cannot be opened in List Details.`); + }); + it('uses the experiment inclusion endpoint with the existing full-list payload', (done) => { experimentDataService.updateInclusionList.mockReturnValue(of({ segment })); diff --git a/packages/frontend/projects/upgrade/src/app/core/segments/list-details.data.service.ts b/packages/frontend/projects/upgrade/src/app/core/segments/list-details.data.service.ts index 330fac664c..27bcf01354 100644 --- a/packages/frontend/projects/upgrade/src/app/core/segments/list-details.data.service.ts +++ b/packages/frontend/projects/upgrade/src/app/core/segments/list-details.data.service.ts @@ -1,6 +1,6 @@ import { Injectable } from '@angular/core'; import { Observable, map, switchMap } from 'rxjs'; -import { EXPERIMENT_STATE, LIST_FILTER_MODE } from 'upgrade_types'; +import { EXPERIMENT_STATE, LIST_FILTER_MODE, STANDARD_LIST_TYPE, normalizeStandardListType } from 'upgrade_types'; import { ExperimentDataService } from '../experiments/experiments.data.service'; import { Experiment } from '../experiments/store/experiments.model'; import { FeatureFlagsDataService } from '../feature-flags/feature-flags.data.service'; @@ -30,9 +30,17 @@ export class ListDetailsDataService { listId: string ): Observable<{ list: Segment; owner: ListDetailsOwner }> { return this.fetchOwner(ownerType, ownerId, filterMode, listId).pipe( - switchMap((owner) => - this.segmentsDataService.fetchSegmentWithMembersById(listId).pipe(map((list) => ({ list, owner }))) - ) + switchMap((owner) => { + this.requireDirectValueList(owner.listType, listId); + return this.segmentsDataService.fetchSegmentWithMembersById(listId).pipe( + map((list) => { + // Legacy wrappers may not have listType populated, but their subsegment + // relationship still identifies them as Segment-backed lists. + this.requireDirectValueList(list.listType ?? owner.listType, listId, list.subSegments); + return { list, owner }; + }) + ); + }) ); } @@ -171,4 +179,11 @@ export class ListDetailsDataService { } return list; } + + private requireDirectValueList(listType: string | undefined, listId: string, subSegments?: Segment[]): void { + const normalizedListType = normalizeStandardListType(listType); + if (normalizedListType === STANDARD_LIST_TYPE.SEGMENT || (!normalizedListType && (subSegments?.length ?? 0) > 0)) { + throw new Error(`Segment-backed list ${listId} cannot be opened in List Details.`); + } + } } From 48fa029406fe4fc0a83c07f17375c8b7f569f0be Mon Sep 17 00:00:00 2001 From: Zack Lee Date: Sat, 22 Aug 2026 20:45:58 -0400 Subject: [PATCH 22/33] fix: restore simple list CSV import parsing --- .../core/segments/list-values.utils.spec.ts | 31 ++++---- .../app/core/segments/list-values.utils.ts | 78 +++---------------- 2 files changed, 23 insertions(+), 86 deletions(-) diff --git a/packages/frontend/projects/upgrade/src/app/core/segments/list-values.utils.spec.ts b/packages/frontend/projects/upgrade/src/app/core/segments/list-values.utils.spec.ts index 6368b4984b..3df8715518 100644 --- a/packages/frontend/projects/upgrade/src/app/core/segments/list-values.utils.spec.ts +++ b/packages/frontend/projects/upgrade/src/app/core/segments/list-values.utils.spec.ts @@ -52,23 +52,23 @@ describe('list values utilities', () => { }); }); - it('parses quoted IDs and escaped quotes', () => { - expect(parseSingleColumnCSV('"06df769b-740e-47f6-8548-2a52be1ab4be"\n"say ""hello"""')).toEqual([ - '06df769b-740e-47f6-8548-2a52be1ab4be', + it('treats quotation marks as value characters', () => { + expect(parseSingleColumnCSV('"06df769b-740e-47f6-8548-2a52be1ab4be"\nsay "hello"\n"unterminated')).toEqual([ + '"06df769b-740e-47f6-8548-2a52be1ab4be"', 'say "hello"', + '"unterminated', ]); }); - it('rejects separators inside quoted values', () => { - expect(() => parseSingleColumnCSV('"school,one"')).toThrow( - 'CSV values cannot contain commas, tabs, or line breaks' - ); - expect(() => parseSingleColumnCSV('"school\tone"')).toThrow( - 'CSV values cannot contain commas, tabs, or line breaks' - ); - expect(() => parseSingleColumnCSV('"school\r\none"')).toThrow( - 'CSV values cannot contain commas, tabs, or line breaks' - ); + it('preserves raw exported values when they are imported again', () => { + const values = ['plain', '"abc"', 'say "hello"', '=SUM(A1:A2)', "'=SUM(A1:A2)"]; + + expect(parseSingleColumnCSV(values.join('\n'))).toEqual(values); + }); + + it('rejects commas and tabs within values', () => { + expect(() => parseSingleColumnCSV('school,one')).toThrow('CSV should contain only one column'); + expect(() => parseSingleColumnCSV('school\tone')).toThrow('CSV values cannot contain tabs'); }); it('preserves formula-like prefixes and leading apostrophes', () => { @@ -82,10 +82,5 @@ describe('list values utilities', () => { expect(() => parseSingleColumnCSV('one,two')).toThrow('CSV should contain only one column'); expect(() => parseSingleColumnCSV('"one",two')).toThrow('CSV should contain only one column'); }); - - it('rejects malformed quoted values', () => { - expect(() => parseSingleColumnCSV('"unterminated')).toThrow('CSV contains malformed quoting'); - expect(() => parseSingleColumnCSV('"one"two')).toThrow('CSV contains malformed quoting'); - }); }); }); diff --git a/packages/frontend/projects/upgrade/src/app/core/segments/list-values.utils.ts b/packages/frontend/projects/upgrade/src/app/core/segments/list-values.utils.ts index 122ea85522..8db2316b86 100644 --- a/packages/frontend/projects/upgrade/src/app/core/segments/list-values.utils.ts +++ b/packages/frontend/projects/upgrade/src/app/core/segments/list-values.utils.ts @@ -45,80 +45,22 @@ export function mergeUniqueListValues(existingValues: string[], incomingValues: }; } -/** Parses an RFC-style CSV while rejecting records that contain more than one field. */ export function parseSingleColumnCSV(content: string): string[] { - const values: string[] = []; - let value = ''; - let isQuoted = false; - let hasClosedQuote = false; - - const addValue = (): void => { - const normalizedValue = value.trim(); - if (normalizedValue) { - if (containsListValueSeparator(normalizedValue)) { - throw new Error('CSV values cannot contain commas, tabs, or line breaks'); - } - values.push(normalizedValue); - } - value = ''; - hasClosedQuote = false; - }; - - for (let index = 0; index < content.length; index++) { - const character = content[index]; - - if (isQuoted) { - if (character === '"') { - if (content[index + 1] === '"') { - value += '"'; - index++; - } else { - isQuoted = false; - hasClosedQuote = true; - } - } else { - value += character; - } - continue; - } - - if (hasClosedQuote) { - if (character === ' ' || character === '\t') { - continue; - } - if (character === ',') { - throw new Error('CSV should contain only one column'); - } - if (character !== '\r' && character !== '\n') { - throw new Error('CSV contains malformed quoting'); - } - } else if (character === '"' && !value.trim()) { - value = ''; - isQuoted = true; - continue; - } else if (character === ',') { - throw new Error('CSV should contain only one column'); - } else if (character !== '\r' && character !== '\n') { - value += character; - continue; - } - - addValue(); - if (character === '\r' && content[index + 1] === '\n') { - index++; - } - } + const values = content + .split(/\r?\n/) + .map((value) => value.trim()) + .filter(Boolean); - if (isQuoted) { - throw new Error('CSV contains malformed quoting'); + if (!values.length) { + throw new Error('CSV file is empty'); } - if (value || hasClosedQuote) { - addValue(); + if (values.some((value) => value.includes(','))) { + throw new Error('CSV should contain only one column'); } - if (!values.length) { - throw new Error('CSV file is empty'); + if (values.some((value) => value.includes('\t'))) { + throw new Error('CSV values cannot contain tabs'); } return values; From 0591e6996298f4fc7c82f00636632ff7b710d1f7 Mon Sep 17 00:00:00 2001 From: Zack Lee Date: Sat, 22 Aug 2026 22:42:56 -0400 Subject: [PATCH 23/33] fix: restore existing list separator behavior --- .../core/segments/list-values.utils.spec.ts | 18 ++++++++++---- .../app/core/segments/list-values.utils.ts | 24 ++++++------------- .../edit-list-value-modal.component.html | 2 +- 3 files changed, 21 insertions(+), 23 deletions(-) diff --git a/packages/frontend/projects/upgrade/src/app/core/segments/list-values.utils.spec.ts b/packages/frontend/projects/upgrade/src/app/core/segments/list-values.utils.spec.ts index 3df8715518..ac5d26330d 100644 --- a/packages/frontend/projects/upgrade/src/app/core/segments/list-values.utils.spec.ts +++ b/packages/frontend/projects/upgrade/src/app/core/segments/list-values.utils.spec.ts @@ -7,8 +7,13 @@ import { describe('list values utilities', () => { describe('splitListValues', () => { - it('splits pasted values on commas, tabs, and new lines', () => { - expect(splitListValues('one, two\tthree\nfour\r\nfive')).toEqual(['one', 'two', 'three', 'four', 'five']); + it('splits pasted values on commas and new lines while preserving internal whitespace', () => { + expect(splitListValues('one, two\tthree\nhello world\r\nfive')).toEqual([ + 'one', + 'two\tthree', + 'hello world', + 'five', + ]); }); it('trims values and drops empty entries', () => { @@ -19,7 +24,7 @@ describe('list values utilities', () => { describe('containsListValueSeparator', () => { it('flags values that the add/import pipelines would split or reject', () => { expect(containsListValueSeparator('schoolA,schoolB')).toBe(true); - expect(containsListValueSeparator('school\tA')).toBe(true); + expect(containsListValueSeparator('school\tA')).toBe(false); expect(containsListValueSeparator('school\nA')).toBe(true); expect(containsListValueSeparator('school-A_1')).toBe(false); }); @@ -66,9 +71,12 @@ describe('list values utilities', () => { expect(parseSingleColumnCSV(values.join('\n'))).toEqual(values); }); - it('rejects commas and tabs within values', () => { + it('preserves internal whitespace', () => { + expect(parseSingleColumnCSV('hello world\nschool\tone')).toEqual(['hello world', 'school\tone']); + }); + + it('rejects multiple columns', () => { expect(() => parseSingleColumnCSV('school,one')).toThrow('CSV should contain only one column'); - expect(() => parseSingleColumnCSV('school\tone')).toThrow('CSV values cannot contain tabs'); }); it('preserves formula-like prefixes and leading apostrophes', () => { diff --git a/packages/frontend/projects/upgrade/src/app/core/segments/list-values.utils.ts b/packages/frontend/projects/upgrade/src/app/core/segments/list-values.utils.ts index 8db2316b86..4a0845430f 100644 --- a/packages/frontend/projects/upgrade/src/app/core/segments/list-values.utils.ts +++ b/packages/frontend/projects/upgrade/src/app/core/segments/list-values.utils.ts @@ -4,7 +4,7 @@ export interface MergeListValuesResult { duplicateValues: string[]; } -const VALUE_SEPARATORS = /[,\t\r\n]+/; +const VALUE_SEPARATORS = /[,\r\n]+/; export function splitListValues(rawValue: string): string[] { return rawValue @@ -46,22 +46,12 @@ export function mergeUniqueListValues(existingValues: string[], incomingValues: } export function parseSingleColumnCSV(content: string): string[] { - const values = content - .split(/\r?\n/) - .map((value) => value.trim()) + const lines = content + .split('\n') + .map((line) => line.trim()) .filter(Boolean); - if (!values.length) { - throw new Error('CSV file is empty'); - } - - if (values.some((value) => value.includes(','))) { - throw new Error('CSV should contain only one column'); - } - - if (values.some((value) => value.includes('\t'))) { - throw new Error('CSV values cannot contain tabs'); - } - - return values; + if (lines.length === 0) throw new Error('CSV file is empty'); + if (lines.some((line) => line.includes(','))) throw new Error('CSV should contain only one column'); + return lines; } diff --git a/packages/frontend/projects/upgrade/src/app/features/dashboard/segments/modals/edit-list-value-modal/edit-list-value-modal.component.html b/packages/frontend/projects/upgrade/src/app/features/dashboard/segments/modals/edit-list-value-modal/edit-list-value-modal.component.html index de1419cc06..33d23c4b40 100644 --- a/packages/frontend/projects/upgrade/src/app/features/dashboard/segments/modals/edit-list-value-modal/edit-list-value-modal.component.html +++ b/packages/frontend/projects/upgrade/src/app/features/dashboard/segments/modals/edit-list-value-modal/edit-list-value-modal.component.html @@ -13,7 +13,7 @@ @if (valueControl.hasError('required')) { Value is required. } @else if (valueControl.hasError('separator')) { - Value cannot contain commas, tabs, or line breaks. + Value cannot contain commas or line breaks. } @else if (valueControl.hasError('duplicate')) { This value already exists in the list. } From cd57be3a2fe42d6731d7449898e86d7873a11f4c Mon Sep 17 00:00:00 2001 From: Zack Lee Date: Sat, 22 Aug 2026 23:41:34 -0400 Subject: [PATCH 24/33] fix: handle legacy segment lists and import modal labels --- .../upsert-list-values-modal.component.html | 8 ++-- ...s-participant-list-table.component.spec.ts | 37 +++++++++++++++++++ ...etails-participant-list-table.component.ts | 9 ++++- 3 files changed, 49 insertions(+), 5 deletions(-) create mode 100644 packages/frontend/projects/upgrade/src/app/shared-standalone-component-lib/components/common-details-participant-list-table/common-details-participant-list-table.component.spec.ts diff --git a/packages/frontend/projects/upgrade/src/app/features/dashboard/segments/modals/upsert-list-values-modal/upsert-list-values-modal.component.html b/packages/frontend/projects/upgrade/src/app/features/dashboard/segments/modals/upsert-list-values-modal/upsert-list-values-modal.component.html index a0b1ad570b..e72f7cf4ff 100644 --- a/packages/frontend/projects/upgrade/src/app/features/dashboard/segments/modals/upsert-list-values-modal/upsert-list-values-modal.component.html +++ b/packages/frontend/projects/upgrade/src/app/features/dashboard/segments/modals/upsert-list-values-modal/upsert-list-values-modal.component.html @@ -23,15 +23,17 @@
} @else {
- {{ fileName }} — {{ importedValues.length }} values + + {{ fileName }} — {{ importedValues.length }} {{ importedValues.length === 1 ? 'value' : 'values' }} +
}
- - + + Append to existing values Replace existing values diff --git a/packages/frontend/projects/upgrade/src/app/shared-standalone-component-lib/components/common-details-participant-list-table/common-details-participant-list-table.component.spec.ts b/packages/frontend/projects/upgrade/src/app/shared-standalone-component-lib/components/common-details-participant-list-table/common-details-participant-list-table.component.spec.ts new file mode 100644 index 0000000000..0c37323312 --- /dev/null +++ b/packages/frontend/projects/upgrade/src/app/shared-standalone-component-lib/components/common-details-participant-list-table/common-details-participant-list-table.component.spec.ts @@ -0,0 +1,37 @@ +import { MemberTypes, Segment } from '../../../core/segments/store/segments.model'; +import { ParticipantListTableRow } from '../../../core/feature-flags/store/feature-flags.model'; +import { SEGMENT_TYPE } from 'upgrade_types'; +import { CommonDetailsParticipantListTableComponent } from './common-details-participant-list-table.component'; + +describe('CommonDetailsParticipantListTableComponent', () => { + let component: CommonDetailsParticipantListTableComponent; + + beforeEach(() => { + component = new CommonDetailsParticipantListTableComponent(); + }); + + it('infers a legacy Segment-backed wrapper from its subsegment relationship', () => { + const rowData = { + listType: undefined, + segment: { + subSegments: [{ type: SEGMENT_TYPE.PUBLIC }], + } as Segment, + } as ParticipantListTableRow; + + expect(component.getFormattedListType(rowData)).toBe(MemberTypes.SEGMENT); + expect(component.isSegmentListType(rowData)).toBe(true); + expect(component.isDirectValueList(rowData)).toBe(false); + expect(component.isPublicSegment(rowData)).toBe(true); + }); + + it('keeps a list without a type or subsegments classified as a direct value list', () => { + const rowData = { + listType: undefined, + segment: { subSegments: [] } as Segment, + } as ParticipantListTableRow; + + expect(component.getFormattedListType(rowData)).toBe(''); + expect(component.isSegmentListType(rowData)).toBe(false); + expect(component.isDirectValueList(rowData)).toBe(true); + }); +}); diff --git a/packages/frontend/projects/upgrade/src/app/shared-standalone-component-lib/components/common-details-participant-list-table/common-details-participant-list-table.component.ts b/packages/frontend/projects/upgrade/src/app/shared-standalone-component-lib/components/common-details-participant-list-table/common-details-participant-list-table.component.ts index f56f06e1ae..df6f1adb13 100644 --- a/packages/frontend/projects/upgrade/src/app/shared-standalone-component-lib/components/common-details-participant-list-table/common-details-participant-list-table.component.ts +++ b/packages/frontend/projects/upgrade/src/app/shared-standalone-component-lib/components/common-details-participant-list-table/common-details-participant-list-table.component.ts @@ -110,11 +110,11 @@ export class CommonDetailsParticipantListTableComponent { } getFormattedListType(rowData: ParticipantListTableRow): string { - return normalizeStandardListType(rowData.listType); + return this.getResolvedListType(rowData); } isSegmentListType(rowData: ParticipantListTableRow): boolean { - return normalizeStandardListType(rowData.listType) === this.memberTypes.SEGMENT; + return this.getResolvedListType(rowData) === this.memberTypes.SEGMENT; } isPublicSegment(rowData: ParticipantListTableRow): boolean { @@ -129,6 +129,11 @@ export class CommonDetailsParticipantListTableComponent { return this.listFilterMode ?? this.tableType; } + private getResolvedListType(rowData: ParticipantListTableRow): string { + const listType = normalizeStandardListType(rowData.listType); + return listType || (rowData.segment?.subSegments?.length ? this.memberTypes.SEGMENT : ''); + } + onSlideToggleChange(event: MatSlideToggleChange, rowData: ParticipantListTableRow): void { const slideToggleEvent = event.source; const action = slideToggleEvent.checked ? PARTICIPANT_LIST_ROW_ACTION.ENABLE : PARTICIPANT_LIST_ROW_ACTION.DISABLE; From 0ef8eef40e482f01a78f4ef9c24479a37f5f3d6c Mon Sep 17 00:00:00 2001 From: Zack Lee Date: Mon, 24 Aug 2026 13:12:44 -0400 Subject: [PATCH 25/33] fix: paginate list detail values --- .../list-details-page.component.html | 8 ++++++++ .../list-details-page.component.ts | 18 +++++++++++++++++- 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/packages/frontend/projects/upgrade/src/app/features/dashboard/segments/pages/list-details-page/list-details-page.component.html b/packages/frontend/projects/upgrade/src/app/features/dashboard/segments/pages/list-details-page/list-details-page.component.html index 9e2d1ac75b..718032dc38 100644 --- a/packages/frontend/projects/upgrade/src/app/features/dashboard/segments/pages/list-details-page/list-details-page.component.html +++ b/packages/frontend/projects/upgrade/src/app/features/dashboard/segments/pages/list-details-page/list-details-page.component.html @@ -114,6 +114,14 @@ + @if (dataSource.filteredData.length > valuesPageSize) { + + }
} diff --git a/packages/frontend/projects/upgrade/src/app/features/dashboard/segments/pages/list-details-page/list-details-page.component.ts b/packages/frontend/projects/upgrade/src/app/features/dashboard/segments/pages/list-details-page/list-details-page.component.ts index 5432346706..e98e68c61f 100644 --- a/packages/frontend/projects/upgrade/src/app/features/dashboard/segments/pages/list-details-page/list-details-page.component.ts +++ b/packages/frontend/projects/upgrade/src/app/features/dashboard/segments/pages/list-details-page/list-details-page.component.ts @@ -1,8 +1,17 @@ import { CommonModule } from '@angular/common'; -import { ChangeDetectionStrategy, ChangeDetectorRef, Component, ErrorHandler, OnDestroy, OnInit } from '@angular/core'; +import { + ChangeDetectionStrategy, + ChangeDetectorRef, + Component, + ErrorHandler, + OnDestroy, + OnInit, + ViewChild, +} from '@angular/core'; import { MatButtonModule } from '@angular/material/button'; import { MatDialog } from '@angular/material/dialog'; import { MatIconModule } from '@angular/material/icon'; +import { MatPaginator, MatPaginatorModule } from '@angular/material/paginator'; import { MatProgressBarModule } from '@angular/material/progress-bar'; import { MatTableDataSource, MatTableModule } from '@angular/material/table'; import { ActivatedRoute, Router } from '@angular/router'; @@ -73,6 +82,7 @@ enum LIST_DETAILS_ACTION { CommonSectionCardActionButtonsComponent, MatButtonModule, MatIconModule, + MatPaginatorModule, MatProgressBarModule, MatTableModule, ], @@ -82,6 +92,7 @@ enum LIST_DETAILS_ACTION { }) export class ListDetailsPageComponent implements OnInit, OnDestroy { readonly displayedColumns = ['value', 'actions']; + readonly valuesPageSize = 10; readonly dataSource = new MatTableDataSource([]); ownerType: LIST_OWNER_TYPE; ownerId = ''; @@ -106,6 +117,10 @@ export class ListDetailsPageComponent implements OnInit, OnDestroy { private hasUpdatePermission = false; private subscriptions = new Subscription(); + @ViewChild(MatPaginator) set paginator(paginator: MatPaginator | undefined) { + this.dataSource.paginator = paginator ?? null; + } + constructor( private route: ActivatedRoute, private router: Router, @@ -246,6 +261,7 @@ export class ListDetailsPageComponent implements OnInit, OnDestroy { search(searchParams: CommonSearchWidgetSearchParams): void { this.valuesSearchString = searchParams.searchString; this.dataSource.filter = this.valuesSearchString.trim().toLowerCase(); + this.dataSource.paginator?.firstPage(); } openAddValuesModal(): void { From 937bd88a50ede242379d3d7b6874fc961a4315d2 Mon Sep 17 00:00:00 2001 From: Zack Lee Date: Mon, 24 Aug 2026 14:19:09 -0400 Subject: [PATCH 26/33] fix: sync list values from update response --- .../pages/list-details-page/list-details-page.component.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/frontend/projects/upgrade/src/app/features/dashboard/segments/pages/list-details-page/list-details-page.component.ts b/packages/frontend/projects/upgrade/src/app/features/dashboard/segments/pages/list-details-page/list-details-page.component.ts index e98e68c61f..c237955dc5 100644 --- a/packages/frontend/projects/upgrade/src/app/features/dashboard/segments/pages/list-details-page/list-details-page.component.ts +++ b/packages/frontend/projects/upgrade/src/app/features/dashboard/segments/pages/list-details-page/list-details-page.component.ts @@ -581,7 +581,7 @@ export class ListDetailsPageComponent implements OnInit, OnDestroy { // The update response re-fetches the segment with its member relations, so this // keeps this.list (the metadata edit modal's full-replacement source) up to date. this.list = { ...this.list, ...updatedList, listType: this.listType }; - this.setValues(values); + this.setValues(this.determineValues(this.list)); this.notificationService.showSuccess(successMessage); this.changeDetectorRef.markForCheck(); }, From c56d83b5f3bab91434198f4f68f1df5ee2ce2ef4 Mon Sep 17 00:00:00 2001 From: Zack Lee Date: Mon, 24 Aug 2026 14:41:41 -0400 Subject: [PATCH 27/33] test: cover post-create list navigation --- .../store/experiments.effects.spec.ts | 108 ++++++++++++++++++ .../segments/store/segments.effects.spec.ts | 72 +++++++++++- 2 files changed, 178 insertions(+), 2 deletions(-) diff --git a/packages/frontend/projects/upgrade/src/app/core/experiments/store/experiments.effects.spec.ts b/packages/frontend/projects/upgrade/src/app/core/experiments/store/experiments.effects.spec.ts index 804d9ab623..22bc2b19a4 100644 --- a/packages/frontend/projects/upgrade/src/app/core/experiments/store/experiments.effects.spec.ts +++ b/packages/frontend/projects/upgrade/src/app/core/experiments/store/experiments.effects.spec.ts @@ -51,6 +51,10 @@ import { actionFetchRewardsDataForExperiment, actionFetchRewardsDataForExperimentSuccess, actionFetchRewardsDataForExperimentFailure, + actionAddExperimentInclusionList, + actionAddExperimentInclusionListSuccess, + actionAddExperimentExclusionList, + actionAddExperimentExclusionListSuccess, } from './experiments.actions'; import { ExperimentEffects } from './experiments.effects'; import { @@ -67,6 +71,8 @@ import { actionExecuteQuery, actionFetchMetrics } from '../../analysis/store/ana import { selectCurrentUser } from '../../auth/store/auth.selectors'; import { UserRole } from '../../users/store/users.model'; import { Environment } from '../../../../environments/environment-types'; +import { LIST_FILTER_MODE, SEGMENT_TYPE } from 'upgrade_types'; +import { ExperimentSegmentListRequest, LIST_OPTION_TYPE } from '../../segments/store/segments.model'; describe('ExperimentEffects', () => { let service: ExperimentEffects; @@ -1376,6 +1382,108 @@ describe('ExperimentEffects', () => { })); }); + describe('add experiment lists', () => { + const experimentId = 'experiment-id'; + const listId = 'list-id'; + const listResponse = { segment: { id: listId } } as any; + + const createListRequest = (listType: string): ExperimentSegmentListRequest => ({ + experimentId, + list: { + name: 'Test list', + description: '', + context: 'test', + type: SEGMENT_TYPE.PRIVATE, + userIds: [], + groups: [], + subSegmentIds: [], + listType, + }, + }); + + describe('addExperimentInclusionList$', () => { + it('should navigate a direct-value list to its List Details page', fakeAsync(() => { + const list = createListRequest(LIST_OPTION_TYPE.INDIVIDUAL); + experimentDataService.addInclusionList = jest.fn().mockReturnValue(of(listResponse)); + + const expectedAction = actionAddExperimentInclusionListSuccess({ listResponse }); + + service.addExperimentInclusionList$.subscribe((result) => { + expect(result).toEqual(expectedAction); + expect(router.navigate).toHaveBeenCalledWith([ + '/home', + 'detail', + experimentId, + 'list', + LIST_FILTER_MODE.INCLUSION, + listId, + ]); + }); + + actions$.next(actionAddExperimentInclusionList({ list })); + + tick(0); + })); + + it('should keep a Segment-backed list on the owner page', fakeAsync(() => { + const list = createListRequest(LIST_OPTION_TYPE.SEGMENT); + experimentDataService.addInclusionList = jest.fn().mockReturnValue(of(listResponse)); + + const expectedAction = actionAddExperimentInclusionListSuccess({ listResponse }); + + service.addExperimentInclusionList$.subscribe((result) => { + expect(result).toEqual(expectedAction); + expect(router.navigate).not.toHaveBeenCalled(); + }); + + actions$.next(actionAddExperimentInclusionList({ list })); + + tick(0); + })); + }); + + describe('addExperimentExclusionList$', () => { + it('should navigate a direct-value list to its List Details page', fakeAsync(() => { + const list = createListRequest(LIST_OPTION_TYPE.INDIVIDUAL); + experimentDataService.addExclusionList = jest.fn().mockReturnValue(of(listResponse)); + + const expectedAction = actionAddExperimentExclusionListSuccess({ listResponse }); + + service.addExperimentExclusionList$.subscribe((result) => { + expect(result).toEqual(expectedAction); + expect(router.navigate).toHaveBeenCalledWith([ + '/home', + 'detail', + experimentId, + 'list', + LIST_FILTER_MODE.EXCLUSION, + listId, + ]); + }); + + actions$.next(actionAddExperimentExclusionList({ list })); + + tick(0); + })); + + it('should keep a Segment-backed list on the owner page', fakeAsync(() => { + const list = createListRequest(LIST_OPTION_TYPE.SEGMENT); + experimentDataService.addExclusionList = jest.fn().mockReturnValue(of(listResponse)); + + const expectedAction = actionAddExperimentExclusionListSuccess({ listResponse }); + + service.addExperimentExclusionList$.subscribe((result) => { + expect(result).toEqual(expectedAction); + expect(router.navigate).not.toHaveBeenCalled(); + }); + + actions$.next(actionAddExperimentExclusionList({ list })); + + tick(0); + })); + }); + }); + describe('fetchRewardsDataForExperiment$', () => { it('should dispatch actionFetchRewardsDataForExperimentSuccess on successful fetch', fakeAsync(() => { const experimentId = 'test-experiment-123'; diff --git a/packages/frontend/projects/upgrade/src/app/core/segments/store/segments.effects.spec.ts b/packages/frontend/projects/upgrade/src/app/core/segments/store/segments.effects.spec.ts index 16d76b10be..b497d9c708 100644 --- a/packages/frontend/projects/upgrade/src/app/core/segments/store/segments.effects.spec.ts +++ b/packages/frontend/projects/upgrade/src/app/core/segments/store/segments.effects.spec.ts @@ -1,9 +1,16 @@ import { fakeAsync, tick } from '@angular/core/testing'; import { ActionsSubject } from '@ngrx/store'; import { BehaviorSubject, of, throwError } from 'rxjs'; -import { SEGMENT_STATUS, SEGMENT_TYPE } from 'upgrade_types'; +import { LIST_FILTER_MODE, SEGMENT_STATUS, SEGMENT_TYPE } from 'upgrade_types'; import { SegmentsEffects } from './segments.effects'; -import { Segment, SegmentFile, SegmentInput, UpsertSegmentType } from './segments.model'; +import { + AddPrivateSegmentListRequest, + LIST_OPTION_TYPE, + Segment, + SegmentFile, + SegmentInput, + UpsertSegmentType, +} from './segments.model'; import { selectAllSegments } from './segments.selectors'; import * as SegmentsActions from './segments.actions'; import { CommonModalEventsService } from '../../../shared/services/common-modal-event.service'; @@ -167,6 +174,67 @@ describe('SegmentsEffects', () => { })); }); + describe('addSegmentList$', () => { + const parentSegmentId = 'parent-segment-id'; + const listId = 'list-id'; + const listResponse = { segment: { ...mockSegment, id: listId } } as any; + + const createListRequest = (listType: string): AddPrivateSegmentListRequest => ({ + id: parentSegmentId, + enabled: true, + listType, + segment: { + name: 'Test list', + description: '', + context: 'test', + type: SEGMENT_TYPE.PRIVATE, + userIds: [], + groups: [], + subSegmentIds: [], + listType, + }, + }); + + it('should navigate a direct-value list to its List Details page', fakeAsync(() => { + const list = createListRequest(LIST_OPTION_TYPE.INDIVIDUAL); + segmentsDataService.addSegmentList = jest.fn().mockReturnValue(of(listResponse)); + + const expectedAction = SegmentsActions.actionAddSegmentListSuccess({ listResponse }); + + service.addSegmentList$.subscribe((result) => { + expect(result).toEqual(expectedAction); + expect(router.navigate).toHaveBeenCalledWith([ + '/segments', + 'detail', + parentSegmentId, + 'list', + LIST_FILTER_MODE.EXCLUSION, + listId, + ]); + }); + + actions$.next(SegmentsActions.actionAddSegmentList({ list })); + + tick(0); + })); + + it('should keep a Segment-backed list on the owner page', fakeAsync(() => { + const list = createListRequest(LIST_OPTION_TYPE.SEGMENT); + segmentsDataService.addSegmentList = jest.fn().mockReturnValue(of(listResponse)); + + const expectedAction = SegmentsActions.actionAddSegmentListSuccess({ listResponse }); + + service.addSegmentList$.subscribe((result) => { + expect(result).toEqual(expectedAction); + expect(router.navigate).not.toHaveBeenCalled(); + }); + + actions$.next(SegmentsActions.actionAddSegmentList({ list })); + + tick(0); + })); + }); + describe('exportSegments$', () => { it('should do nothing if Segment is id', fakeAsync(() => { let neverEmitted = true; From c59b88512bfdd00dc749caf5f4d88a5f798eb3cf Mon Sep 17 00:00:00 2001 From: Zack Lee Date: Mon, 24 Aug 2026 16:13:53 -0400 Subject: [PATCH 28/33] test: streamline list details coverage --- .../list-details.data.service.spec.ts | 42 ++++++++++--------- .../core/segments/list-values.utils.spec.ts | 39 ++--------------- ...bbed-section-card-footer.component.spec.ts | 4 -- 3 files changed, 26 insertions(+), 59 deletions(-) diff --git a/packages/frontend/projects/upgrade/src/app/core/segments/list-details.data.service.spec.ts b/packages/frontend/projects/upgrade/src/app/core/segments/list-details.data.service.spec.ts index c7eb7a08f5..d23bcdd043 100644 --- a/packages/frontend/projects/upgrade/src/app/core/segments/list-details.data.service.spec.ts +++ b/packages/frontend/projects/upgrade/src/app/core/segments/list-details.data.service.spec.ts @@ -125,25 +125,27 @@ describe('ListDetailsDataService', () => { }); }); - it('marks completed and archived experiment owners as read-only', (done) => { - experimentDataService.getExperimentById.mockReturnValue( - of({ - id: 'experiment-id', - name: 'Test experiment', - state: EXPERIMENT_STATE.COMPLETED, - experimentSegmentInclusion: [], - experimentSegmentExclusion: [{ segment }], - }) - ); + it.each([EXPERIMENT_STATE.COMPLETED, EXPERIMENT_STATE.ARCHIVED])( + 'marks %s experiment owners as read-only', + async (state) => { + experimentDataService.getExperimentById.mockReturnValue( + of({ + id: 'experiment-id', + name: 'Test experiment', + state, + experimentSegmentInclusion: [], + experimentSegmentExclusion: [{ segment }], + }) + ); - service - .fetchOwner(LIST_OWNER_TYPE.EXPERIMENT, 'experiment-id', LIST_FILTER_MODE.EXCLUSION, segment.id) - .subscribe((owner) => { - expect(owner.isReadOnly).toBe(true); - expect(owner.listType).toBe(segment.listType); - done(); - }); - }); + const owner = await firstValueFrom( + service.fetchOwner(LIST_OWNER_TYPE.EXPERIMENT, 'experiment-id', LIST_FILTER_MODE.EXCLUSION, segment.id) + ); + + expect(owner.isReadOnly).toBe(true); + expect(owner.listType).toBe(segment.listType); + } + ); it('rejects an experiment list that is not attached to the requested owner and filter mode', async () => { experimentDataService.getExperimentById.mockReturnValue( @@ -340,7 +342,7 @@ describe('ListDetailsDataService', () => { }); }); - it('deletes an experiment list with its owner id', (done) => { + it('uses the experiment inclusion delete endpoint', (done) => { experimentDataService.deleteInclusionList.mockReturnValue(of(undefined)); service @@ -351,7 +353,7 @@ describe('ListDetailsDataService', () => { }); }); - it('deletes a feature flag list with its owner id', (done) => { + it('uses the feature flag exclusion delete endpoint', (done) => { featureFlagsDataService.deleteExclusionList.mockReturnValue(of(undefined)); service diff --git a/packages/frontend/projects/upgrade/src/app/core/segments/list-values.utils.spec.ts b/packages/frontend/projects/upgrade/src/app/core/segments/list-values.utils.spec.ts index ac5d26330d..08516c566c 100644 --- a/packages/frontend/projects/upgrade/src/app/core/segments/list-values.utils.spec.ts +++ b/packages/frontend/projects/upgrade/src/app/core/segments/list-values.utils.spec.ts @@ -8,9 +8,9 @@ import { describe('list values utilities', () => { describe('splitListValues', () => { it('splits pasted values on commas and new lines while preserving internal whitespace', () => { - expect(splitListValues('one, two\tthree\nhello world\r\nfive')).toEqual([ + expect(splitListValues('one, two three\nhello world\r\nfive')).toEqual([ 'one', - 'two\tthree', + 'two three', 'hello world', 'five', ]); @@ -24,7 +24,6 @@ describe('list values utilities', () => { describe('containsListValueSeparator', () => { it('flags values that the add/import pipelines would split or reject', () => { expect(containsListValueSeparator('schoolA,schoolB')).toBe(true); - expect(containsListValueSeparator('school\tA')).toBe(false); expect(containsListValueSeparator('school\nA')).toBe(true); expect(containsListValueSeparator('school-A_1')).toBe(false); }); @@ -49,46 +48,16 @@ describe('list values utilities', () => { expect(parseSingleColumnCSV('one\none\ntwo')).toEqual(['one', 'one', 'two']); }); - it('reports both CSV and existing-list duplicates when parsed rows are merged', () => { - expect(mergeUniqueListValues(['one'], parseSingleColumnCSV('one\none\ntwo'))).toEqual({ - values: ['one', 'two'], - addedValues: ['two'], - duplicateValues: ['one', 'one'], - }); - }); - - it('treats quotation marks as value characters', () => { - expect(parseSingleColumnCSV('"06df769b-740e-47f6-8548-2a52be1ab4be"\nsay "hello"\n"unterminated')).toEqual([ - '"06df769b-740e-47f6-8548-2a52be1ab4be"', - 'say "hello"', - '"unterminated', - ]); - }); - - it('preserves raw exported values when they are imported again', () => { - const values = ['plain', '"abc"', 'say "hello"', '=SUM(A1:A2)', "'=SUM(A1:A2)"]; - - expect(parseSingleColumnCSV(values.join('\n'))).toEqual(values); - }); - it('preserves internal whitespace', () => { - expect(parseSingleColumnCSV('hello world\nschool\tone')).toEqual(['hello world', 'school\tone']); + expect(parseSingleColumnCSV('hello world')).toEqual(['hello world']); }); it('rejects multiple columns', () => { expect(() => parseSingleColumnCSV('school,one')).toThrow('CSV should contain only one column'); }); - it('preserves formula-like prefixes and leading apostrophes', () => { - expect( - parseSingleColumnCSV("=SUM(A1:A2)\n+cmd\n-1+2\n@SUM(A1:A2)\n'=SUM(A1:A2)\n''=SUM(A1:A2)\n'school") - ).toEqual(['=SUM(A1:A2)', '+cmd', '-1+2', '@SUM(A1:A2)', "'=SUM(A1:A2)", "''=SUM(A1:A2)", "'school"]); - }); - - it('rejects empty and multi-column CSV files', () => { + it('rejects an empty CSV file', () => { expect(() => parseSingleColumnCSV('')).toThrow('CSV file is empty'); - expect(() => parseSingleColumnCSV('one,two')).toThrow('CSV should contain only one column'); - expect(() => parseSingleColumnCSV('"one",two')).toThrow('CSV should contain only one column'); }); }); }); diff --git a/packages/frontend/projects/upgrade/src/app/shared-standalone-component-lib/components/common-tabbed-section-card-footer/common-tabbed-section-card-footer.component.spec.ts b/packages/frontend/projects/upgrade/src/app/shared-standalone-component-lib/components/common-tabbed-section-card-footer/common-tabbed-section-card-footer.component.spec.ts index 0bb0095426..98c155052d 100644 --- a/packages/frontend/projects/upgrade/src/app/shared-standalone-component-lib/components/common-tabbed-section-card-footer/common-tabbed-section-card-footer.component.spec.ts +++ b/packages/frontend/projects/upgrade/src/app/shared-standalone-component-lib/components/common-tabbed-section-card-footer/common-tabbed-section-card-footer.component.spec.ts @@ -55,10 +55,6 @@ describe('CommonTabbedSectionCardFooterComponent', () => { host.onSelectedTabChange.mockClear(); }); - it('should create', () => { - expect(fixture.componentInstance).toBeTruthy(); - }); - it('should not navigate when tabLabels is replaced with equal labels but new object identities', async () => { // Simulates a details page re-emitting tab labels after a store update (e.g. a list // was added and the parent entity was replaced). The rebuilt labels must not cause a From e438af20bae76150cc21e97ce85d4d866f4406b8 Mon Sep 17 00:00:00 2001 From: Zack Lee Date: Mon, 24 Aug 2026 16:45:11 -0400 Subject: [PATCH 29/33] fix: align list details actions with experiment state restrictions --- .../list-details.data.service.spec.ts | 67 +++++++++++++------ .../segments/list-details.data.service.ts | 25 ++++++- .../app/core/segments/store/segments.model.ts | 10 ++- .../list-details-page.component.html | 37 +++++++--- .../list-details-page.component.ts | 21 ++++-- 5 files changed, 119 insertions(+), 41 deletions(-) diff --git a/packages/frontend/projects/upgrade/src/app/core/segments/list-details.data.service.spec.ts b/packages/frontend/projects/upgrade/src/app/core/segments/list-details.data.service.spec.ts index d23bcdd043..a3b2d3fc84 100644 --- a/packages/frontend/projects/upgrade/src/app/core/segments/list-details.data.service.spec.ts +++ b/packages/frontend/projects/upgrade/src/app/core/segments/list-details.data.service.spec.ts @@ -119,33 +119,56 @@ describe('ListDetailsDataService', () => { name: 'Test experiment', type: LIST_OWNER_TYPE.EXPERIMENT, listType: 'Individual', - isReadOnly: false, + restriction: { isDisabled: false }, }); done(); }); }); - it.each([EXPERIMENT_STATE.COMPLETED, EXPERIMENT_STATE.ARCHIVED])( - 'marks %s experiment owners as read-only', - async (state) => { - experimentDataService.getExperimentById.mockReturnValue( - of({ - id: 'experiment-id', - name: 'Test experiment', - state, - experimentSegmentInclusion: [], - experimentSegmentExclusion: [{ segment }], - }) - ); - - const owner = await firstValueFrom( - service.fetchOwner(LIST_OWNER_TYPE.EXPERIMENT, 'experiment-id', LIST_FILTER_MODE.EXCLUSION, segment.id) - ); - - expect(owner.isReadOnly).toBe(true); - expect(owner.listType).toBe(segment.listType); - } - ); + it('disables completed experiment list actions without hiding them', async () => { + experimentDataService.getExperimentById.mockReturnValue( + of({ + id: 'experiment-id', + name: 'Test experiment', + state: EXPERIMENT_STATE.COMPLETED, + experimentSegmentInclusion: [], + experimentSegmentExclusion: [{ segment }], + }) + ); + + const owner = await firstValueFrom( + service.fetchOwner(LIST_OWNER_TYPE.EXPERIMENT, 'experiment-id', LIST_FILTER_MODE.EXCLUSION, segment.id) + ); + + expect(owner.restriction).toEqual({ + isDisabled: true, + tooltipKey: 'experiments.details.restrictions.experiment-completed.text', + }); + expect(owner.listType).toBe(segment.listType); + }); + + it('hides archived experiment list actions', async () => { + experimentDataService.getExperimentById.mockReturnValue( + of({ + id: 'experiment-id', + name: 'Test experiment', + state: EXPERIMENT_STATE.ARCHIVED, + experimentSegmentInclusion: [], + experimentSegmentExclusion: [{ segment }], + }) + ); + + const owner = await firstValueFrom( + service.fetchOwner(LIST_OWNER_TYPE.EXPERIMENT, 'experiment-id', LIST_FILTER_MODE.EXCLUSION, segment.id) + ); + + expect(owner.restriction).toEqual({ + isDisabled: true, + shouldHideActions: true, + tooltipKey: 'experiments.details.restrictions.experiment-archived.text', + }); + expect(owner.listType).toBe(segment.listType); + }); it('rejects an experiment list that is not attached to the requested owner and filter mode', async () => { experimentDataService.getExperimentById.mockReturnValue( diff --git a/packages/frontend/projects/upgrade/src/app/core/segments/list-details.data.service.ts b/packages/frontend/projects/upgrade/src/app/core/segments/list-details.data.service.ts index 27bcf01354..82c9871aec 100644 --- a/packages/frontend/projects/upgrade/src/app/core/segments/list-details.data.service.ts +++ b/packages/frontend/projects/upgrade/src/app/core/segments/list-details.data.service.ts @@ -12,6 +12,7 @@ import { ExperimentSegmentListRequest, LIST_OWNER_TYPE, ListDetailsOwner, + ListDetailsOwnerRestriction, Segment, } from './store/segments.model'; @@ -70,8 +71,7 @@ export class ListDetailsDataService { // The experiment response carries the inferred list type for legacy lists // whose own segment row predates the listType column. listType: list.segment?.listType, - // The owner details page locks list changes for these states, so lock them here too. - isReadOnly: [EXPERIMENT_STATE.COMPLETED, EXPERIMENT_STATE.ARCHIVED].includes(experiment.state), + restriction: this.getExperimentListRestriction(experiment.state), }; }) ); @@ -119,6 +119,27 @@ export class ListDetailsDataService { } } + private getExperimentListRestriction(state: EXPERIMENT_STATE): ListDetailsOwnerRestriction { + // Match the Experiment Details section-card behavior: completed actions remain + // visible but disabled, while archived actions are hidden. + if (state === EXPERIMENT_STATE.ARCHIVED) { + return { + isDisabled: true, + shouldHideActions: true, + tooltipKey: 'experiments.details.restrictions.experiment-archived.text', + }; + } + + if (state === EXPERIMENT_STATE.COMPLETED) { + return { + isDisabled: true, + tooltipKey: 'experiments.details.restrictions.experiment-completed.text', + }; + } + + return { isDisabled: false }; + } + updateList( ownerType: LIST_OWNER_TYPE, filterMode: LIST_FILTER_MODE, diff --git a/packages/frontend/projects/upgrade/src/app/core/segments/store/segments.model.ts b/packages/frontend/projects/upgrade/src/app/core/segments/store/segments.model.ts index 058c05d322..382a2fc5f1 100644 --- a/packages/frontend/projects/upgrade/src/app/core/segments/store/segments.model.ts +++ b/packages/frontend/projects/upgrade/src/app/core/segments/store/segments.model.ts @@ -316,6 +316,12 @@ export enum LIST_OWNER_TYPE { SEGMENT = 'segment', } +export interface ListDetailsOwnerRestriction { + isDisabled: boolean; + tooltipKey?: string; + shouldHideActions?: boolean; +} + export interface ListDetailsOwner { id: string; name: string; @@ -325,8 +331,8 @@ export interface ListDetailsOwner { // Owner-side list type, used as a fallback when the list's own segment row predates // the listType column (flag join rows store it; experiment responses infer it). listType?: string; - // True when the owner disallows list changes (e.g. completed/archived experiments). - isReadOnly?: boolean; + // Mirrors the owner details page's disabled/hidden action behavior. + restriction?: ListDetailsOwnerRestriction; } export const PRIVATE_SEGMENT_LIST_FORM_FIELDS = { diff --git a/packages/frontend/projects/upgrade/src/app/features/dashboard/segments/pages/list-details-page/list-details-page.component.html b/packages/frontend/projects/upgrade/src/app/features/dashboard/segments/pages/list-details-page/list-details-page.component.html index 718032dc38..d36baf092a 100644 --- a/packages/frontend/projects/upgrade/src/app/features/dashboard/segments/pages/list-details-page/list-details-page.component.html +++ b/packages/frontend/projects/upgrade/src/app/features/dashboard/segments/pages/list-details-page/list-details-page.component.html @@ -11,7 +11,7 @@
@if (isLoading) { - } @if (list && owner) { + } @if (list && owner) { @let restrictionTooltip = ownerRestriction.tooltipKey | translate; - @if (canManage) { Actions } + + @if (canShowMutationActions) { Actions } + - @if (canManage) { -
+ @if (canShowMutationActions) { +
-
+
diff --git a/packages/frontend/projects/upgrade/src/app/features/dashboard/segments/modals/upsert-list-values-modal/upsert-list-values-modal.component.scss b/packages/frontend/projects/upgrade/src/app/features/dashboard/segments/modals/upsert-list-values-modal/upsert-list-values-modal.component.scss index 9f44de2011..e029146339 100644 --- a/packages/frontend/projects/upgrade/src/app/features/dashboard/segments/modals/upsert-list-values-modal/upsert-list-values-modal.component.scss +++ b/packages/frontend/projects/upgrade/src/app/features/dashboard/segments/modals/upsert-list-values-modal/upsert-list-values-modal.component.scss @@ -8,11 +8,6 @@ } } -.entry-hint { - margin: -8px 0 0; - color: var(--dark-grey); -} - .drag-drop-container { display: flex; flex-direction: column; diff --git a/packages/frontend/projects/upgrade/src/app/features/dashboard/segments/modals/upsert-list-values-modal/upsert-list-values-modal.component.ts b/packages/frontend/projects/upgrade/src/app/features/dashboard/segments/modals/upsert-list-values-modal/upsert-list-values-modal.component.ts index 25c86414f6..4db344bd3a 100644 --- a/packages/frontend/projects/upgrade/src/app/features/dashboard/segments/modals/upsert-list-values-modal/upsert-list-values-modal.component.ts +++ b/packages/frontend/projects/upgrade/src/app/features/dashboard/segments/modals/upsert-list-values-modal/upsert-list-values-modal.component.ts @@ -1,6 +1,6 @@ import { ChangeDetectionStrategy, ChangeDetectorRef, Component, Inject, OnDestroy } from '@angular/core'; import { CommonModule } from '@angular/common'; -import { FormsModule } from '@angular/forms'; +import { FormControl, FormsModule, ReactiveFormsModule } from '@angular/forms'; import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; import { MatFormFieldModule } from '@angular/material/form-field'; import { MatInputModule } from '@angular/material/input'; @@ -9,7 +9,11 @@ import { TranslateModule } from '@ngx-translate/core'; import { CommonLearnMoreLinkComponent, CommonModalComponent } from '@shared-component-lib'; import { CommonImportContainerComponent } from '@shared-component-lib/common-import-container/common-import-container.component'; import { FILE_TYPE } from 'upgrade_types'; -import { parseSingleColumnCSV, splitListValues } from '../../../../../core/segments/list-values.utils'; +import { + containsTabCharacter, + parseSingleColumnCSV, + splitListValues, +} from '../../../../../core/segments/list-values.utils'; export enum LIST_VALUES_UPDATE_MODE { APPEND = 'append', @@ -31,6 +35,7 @@ export interface UpsertListValuesModalResult { imports: [ CommonModule, FormsModule, + ReactiveFormsModule, MatFormFieldModule, MatInputModule, MatRadioModule, @@ -44,7 +49,10 @@ export interface UpsertListValuesModalResult { changeDetection: ChangeDetectionStrategy.OnPush, }) export class UpsertListValuesModalComponent implements OnDestroy { - rawValues = ''; + readonly rawValuesControl = new FormControl('', { + nonNullable: true, + validators: [(control) => (containsTabCharacter(control.value) ? { tab: true } : null)], + }); importedValues: string[] = []; fileName = ''; errorMessage = ''; @@ -64,15 +72,24 @@ export class UpsertListValuesModalComponent implements OnDestroy { } get values(): string[] { - return this.data.importOnly ? this.importedValues : splitListValues(this.rawValues); + return this.data.importOnly ? this.importedValues : splitListValues(this.rawValuesControl.value); } get primaryActionLabel(): string { return this.data.importOnly ? 'Import' : 'Add'; } + get hasUnsupportedTab(): boolean { + return !this.data.importOnly && this.rawValuesControl.hasError('tab'); + } + get isPrimaryActionDisabled(): boolean { - return this.values.length === 0 || !!this.errorMessage || (this.data.importOnly && !this.fileName); + return ( + this.values.length === 0 || + this.hasUnsupportedTab || + !!this.errorMessage || + (this.data.importOnly && !this.fileName) + ); } onFilesSelected(files: File[]): void { From 4246f4d7b0bc967bc386cded91205f1360db4934 Mon Sep 17 00:00:00 2001 From: Zack Lee Date: Mon, 24 Aug 2026 19:59:14 -0400 Subject: [PATCH 32/33] fix: align filtered list empty-state spacing --- .../list-details-page/list-details-page.component.html | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/frontend/projects/upgrade/src/app/features/dashboard/segments/pages/list-details-page/list-details-page.component.html b/packages/frontend/projects/upgrade/src/app/features/dashboard/segments/pages/list-details-page/list-details-page.component.html index d36baf092a..527f77779d 100644 --- a/packages/frontend/projects/upgrade/src/app/features/dashboard/segments/pages/list-details-page/list-details-page.component.html +++ b/packages/frontend/projects/upgrade/src/app/features/dashboard/segments/pages/list-details-page/list-details-page.component.html @@ -73,7 +73,12 @@ @if (isSaving) { } - +
From 2dc730f72c74f184f9cdc81e0ec6eaf640e8aca2 Mon Sep 17 00:00:00 2001 From: Zack Lee Date: Mon, 24 Aug 2026 20:22:23 -0400 Subject: [PATCH 33/33] fix: display specific CSV import errors --- .../upsert-list-values-modal.component.html | 1 + .../common-import-container.component.html | 2 +- .../common-import-container.component.ts | 2 ++ 3 files changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/frontend/projects/upgrade/src/app/features/dashboard/segments/modals/upsert-list-values-modal/upsert-list-values-modal.component.html b/packages/frontend/projects/upgrade/src/app/features/dashboard/segments/modals/upsert-list-values-modal/upsert-list-values-modal.component.html index a00c2794f8..9356646a68 100644 --- a/packages/frontend/projects/upgrade/src/app/features/dashboard/segments/modals/upsert-list-values-modal/upsert-list-values-modal.component.html +++ b/packages/frontend/projects/upgrade/src/app/features/dashboard/segments/modals/upsert-list-values-modal/upsert-list-values-modal.component.html @@ -14,6 +14,7 @@ buttonLabel="Choose CSV" [showCloseButton]="false" [importFailed]="!!errorMessage" + [importFailedMessage]="errorMessage" (filesSelected)="onFilesSelected($event)" >

diff --git a/packages/frontend/projects/upgrade/src/app/shared-standalone-component-lib/components/common-import-container/common-import-container.component.html b/packages/frontend/projects/upgrade/src/app/shared-standalone-component-lib/components/common-import-container/common-import-container.component.html index 801f36c54f..a24f51e2a6 100644 --- a/packages/frontend/projects/upgrade/src/app/shared-standalone-component-lib/components/common-import-container/common-import-container.component.html +++ b/packages/frontend/projects/upgrade/src/app/shared-standalone-component-lib/components/common-import-container/common-import-container.component.html @@ -19,7 +19,7 @@ @if (importFailed) { - {{ 'feature-flags.upsert-list-modal.import-csv.error.message.text' | translate }} + {{ importFailedMessage || ('feature-flags.upsert-list-modal.import-csv.error.message.text' | translate) }} } diff --git a/packages/frontend/projects/upgrade/src/app/shared-standalone-component-lib/components/common-import-container/common-import-container.component.ts b/packages/frontend/projects/upgrade/src/app/shared-standalone-component-lib/components/common-import-container/common-import-container.component.ts index cfee4432f8..8bb15023cc 100644 --- a/packages/frontend/projects/upgrade/src/app/shared-standalone-component-lib/components/common-import-container/common-import-container.component.ts +++ b/packages/frontend/projects/upgrade/src/app/shared-standalone-component-lib/components/common-import-container/common-import-container.component.ts @@ -12,6 +12,7 @@ import { FILE_TYPE } from 'upgrade_types'; * The component accepts the following inputs: * - `fileType`: A string representing the accepted file type (e.g., '.json'). Only files with this extension can be selected or dropped. * - `buttonLabel`: A string representing the label text of the button. Defaults to 'Upload File'. + * - `importFailedMessage`: An optional specific error message. The existing generic message is used when omitted. * - `showCloseButton`: Whether to show the CSV close button. Defaults to true. * * The component emits the following outputs: @@ -39,6 +40,7 @@ export class CommonImportContainerComponent { @Input() fileType!: FILE_TYPE; @Input() buttonLabel!: string; @Input() importFailed = false; + @Input() importFailedMessage = ''; @Input() showCloseButton = true; @Output() closeButtonClick = new EventEmitter(); @Output() filesSelected = new EventEmitter();

Value {{ row.value }}