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 {