diff --git a/packages/frontend/projects/upgrade/src/app/core/experiments/store/experiments.effects.spec.ts b/packages/frontend/projects/upgrade/src/app/core/experiments/store/experiments.effects.spec.ts index 804d9ab623..22bc2b19a4 100644 --- a/packages/frontend/projects/upgrade/src/app/core/experiments/store/experiments.effects.spec.ts +++ b/packages/frontend/projects/upgrade/src/app/core/experiments/store/experiments.effects.spec.ts @@ -51,6 +51,10 @@ import { actionFetchRewardsDataForExperiment, actionFetchRewardsDataForExperimentSuccess, actionFetchRewardsDataForExperimentFailure, + actionAddExperimentInclusionList, + actionAddExperimentInclusionListSuccess, + actionAddExperimentExclusionList, + actionAddExperimentExclusionListSuccess, } from './experiments.actions'; import { ExperimentEffects } from './experiments.effects'; import { @@ -67,6 +71,8 @@ import { actionExecuteQuery, actionFetchMetrics } from '../../analysis/store/ana import { selectCurrentUser } from '../../auth/store/auth.selectors'; import { UserRole } from '../../users/store/users.model'; import { Environment } from '../../../../environments/environment-types'; +import { LIST_FILTER_MODE, SEGMENT_TYPE } from 'upgrade_types'; +import { ExperimentSegmentListRequest, LIST_OPTION_TYPE } from '../../segments/store/segments.model'; describe('ExperimentEffects', () => { let service: ExperimentEffects; @@ -1376,6 +1382,108 @@ describe('ExperimentEffects', () => { })); }); + describe('add experiment lists', () => { + const experimentId = 'experiment-id'; + const listId = 'list-id'; + const listResponse = { segment: { id: listId } } as any; + + const createListRequest = (listType: string): ExperimentSegmentListRequest => ({ + experimentId, + list: { + name: 'Test list', + description: '', + context: 'test', + type: SEGMENT_TYPE.PRIVATE, + userIds: [], + groups: [], + subSegmentIds: [], + listType, + }, + }); + + describe('addExperimentInclusionList$', () => { + it('should navigate a direct-value list to its List Details page', fakeAsync(() => { + const list = createListRequest(LIST_OPTION_TYPE.INDIVIDUAL); + experimentDataService.addInclusionList = jest.fn().mockReturnValue(of(listResponse)); + + const expectedAction = actionAddExperimentInclusionListSuccess({ listResponse }); + + service.addExperimentInclusionList$.subscribe((result) => { + expect(result).toEqual(expectedAction); + expect(router.navigate).toHaveBeenCalledWith([ + '/home', + 'detail', + experimentId, + 'list', + LIST_FILTER_MODE.INCLUSION, + listId, + ]); + }); + + actions$.next(actionAddExperimentInclusionList({ list })); + + tick(0); + })); + + it('should keep a Segment-backed list on the owner page', fakeAsync(() => { + const list = createListRequest(LIST_OPTION_TYPE.SEGMENT); + experimentDataService.addInclusionList = jest.fn().mockReturnValue(of(listResponse)); + + const expectedAction = actionAddExperimentInclusionListSuccess({ listResponse }); + + service.addExperimentInclusionList$.subscribe((result) => { + expect(result).toEqual(expectedAction); + expect(router.navigate).not.toHaveBeenCalled(); + }); + + actions$.next(actionAddExperimentInclusionList({ list })); + + tick(0); + })); + }); + + describe('addExperimentExclusionList$', () => { + it('should navigate a direct-value list to its List Details page', fakeAsync(() => { + const list = createListRequest(LIST_OPTION_TYPE.INDIVIDUAL); + experimentDataService.addExclusionList = jest.fn().mockReturnValue(of(listResponse)); + + const expectedAction = actionAddExperimentExclusionListSuccess({ listResponse }); + + service.addExperimentExclusionList$.subscribe((result) => { + expect(result).toEqual(expectedAction); + expect(router.navigate).toHaveBeenCalledWith([ + '/home', + 'detail', + experimentId, + 'list', + LIST_FILTER_MODE.EXCLUSION, + listId, + ]); + }); + + actions$.next(actionAddExperimentExclusionList({ list })); + + tick(0); + })); + + it('should keep a Segment-backed list on the owner page', fakeAsync(() => { + const list = createListRequest(LIST_OPTION_TYPE.SEGMENT); + experimentDataService.addExclusionList = jest.fn().mockReturnValue(of(listResponse)); + + const expectedAction = actionAddExperimentExclusionListSuccess({ listResponse }); + + service.addExperimentExclusionList$.subscribe((result) => { + expect(result).toEqual(expectedAction); + expect(router.navigate).not.toHaveBeenCalled(); + }); + + actions$.next(actionAddExperimentExclusionList({ list })); + + tick(0); + })); + }); + }); + describe('fetchRewardsDataForExperiment$', () => { it('should dispatch actionFetchRewardsDataForExperimentSuccess on successful fetch', fakeAsync(() => { const experimentId = 'test-experiment-123'; diff --git a/packages/frontend/projects/upgrade/src/app/core/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..7398c640df --- /dev/null +++ b/packages/frontend/projects/upgrade/src/app/core/segments/list-details.data.service.spec.ts @@ -0,0 +1,472 @@ +import { firstValueFrom, of } from 'rxjs'; +import { EXPERIMENT_STATE, LIST_FILTER_MODE, SEGMENT_TYPE } from 'upgrade_types'; +import { ExperimentDataService } from '../experiments/experiments.data.service'; +import { FeatureFlagsDataService } from '../feature-flags/feature-flags.data.service'; +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 referencedSegment = { + id: 'referenced-segment-id', + name: 'Referenced segment', + type: SEGMENT_TYPE.PUBLIC, + } as Segment; + + const segmentBackedList = { + ...segment, + listType: 'Segment', + subSegments: [referencedSegment], + } as Segment; + + const segmentRequest: EditPrivateSegmentListDetails = { + id: segment.id, + name: segment.name, + 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 and list type', (done) => { + featureFlagsDataService.fetchFeatureFlagById.mockReturnValue( + of({ + id: 'flag-id', + name: 'Test flag', + featureFlagSegmentInclusion: [{ segment, enabled: true, listType: 'Individual' }], + 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, + listType: 'Individual', + }); + done(); + }); + }); + + it('loads an experiment owner with the inferred owner-side list type', (done) => { + experimentDataService.getExperimentById.mockReturnValue( + of({ + id: 'experiment-id', + name: 'Test experiment', + state: EXPERIMENT_STATE.ENROLLING, + // The experiment response carries the (possibly inferred) list type even when + // the list's own segment row predates the listType column. + experimentSegmentInclusion: [{ segment: { ...segment, listType: 'Individual' } }], + experimentSegmentExclusion: [], + }) + ); + + service + .fetchOwner(LIST_OWNER_TYPE.EXPERIMENT, 'experiment-id', LIST_FILTER_MODE.INCLUSION, segment.id) + .subscribe((owner) => { + expect(owner).toEqual({ + id: 'experiment-id', + name: 'Test experiment', + type: LIST_OWNER_TYPE.EXPERIMENT, + listType: 'Individual', + restriction: { isDisabled: false }, + }); + done(); + }); + }); + + it('disables completed experiment list actions without hiding them', async () => { + experimentDataService.getExperimentById.mockReturnValue( + of({ + id: 'experiment-id', + name: 'Test experiment', + state: EXPERIMENT_STATE.COMPLETED, + experimentSegmentInclusion: [], + experimentSegmentExclusion: [{ segment }], + }) + ); + + const owner = await firstValueFrom( + service.fetchOwner(LIST_OWNER_TYPE.EXPERIMENT, 'experiment-id', LIST_FILTER_MODE.EXCLUSION, segment.id) + ); + + expect(owner.restriction).toEqual({ + isDisabled: true, + tooltipKey: 'experiments.details.restrictions.experiment-completed.text', + }); + expect(owner.listType).toBe(segment.listType); + }); + + it('hides archived experiment list actions', async () => { + experimentDataService.getExperimentById.mockReturnValue( + of({ + id: 'experiment-id', + name: 'Test experiment', + state: EXPERIMENT_STATE.ARCHIVED, + experimentSegmentInclusion: [], + experimentSegmentExclusion: [{ segment }], + }) + ); + + const owner = await firstValueFrom( + service.fetchOwner(LIST_OWNER_TYPE.EXPERIMENT, 'experiment-id', LIST_FILTER_MODE.EXCLUSION, segment.id) + ); + + expect(owner.restriction).toEqual({ + isDisabled: true, + shouldHideActions: true, + tooltipKey: 'experiments.details.restrictions.experiment-archived.text', + }); + expect(owner.listType).toBe(segment.listType); + }); + + it('rejects an experiment list that is not attached to the requested owner and filter mode', async () => { + experimentDataService.getExperimentById.mockReturnValue( + of({ + id: 'experiment-id', + name: 'Test experiment', + state: EXPERIMENT_STATE.ENROLLING, + experimentSegmentInclusion: [], + experimentSegmentExclusion: [{ segment }], + }) + ); + + await expect( + firstValueFrom( + service.fetchListDetails(LIST_OWNER_TYPE.EXPERIMENT, 'experiment-id', LIST_FILTER_MODE.INCLUSION, segment.id) + ) + ).rejects.toThrow(`List ${segment.id} does not belong to owner experiment-id.`); + expect(segmentsDataService.fetchSegmentWithMembersById).not.toHaveBeenCalled(); + }); + + it('rejects a feature flag list that is not attached to the requested owner', async () => { + featureFlagsDataService.fetchFeatureFlagById.mockReturnValue( + of({ + id: 'flag-id', + name: 'Test flag', + featureFlagSegmentInclusion: [], + featureFlagSegmentExclusion: [], + }) + ); + + await expect( + firstValueFrom( + service.fetchOwner(LIST_OWNER_TYPE.FEATURE_FLAG, 'flag-id', LIST_FILTER_MODE.INCLUSION, segment.id) + ) + ).rejects.toThrow(`List ${segment.id} does not belong to owner flag-id.`); + }); + + it('rejects a nested list that is not attached to the requested segment', async () => { + segmentsDataService.getSegmentById.mockReturnValue( + of({ segment: { id: 'parent-id', name: 'Parent segment', subSegments: [] } }) + ); + + await expect( + firstValueFrom(service.fetchOwner(LIST_OWNER_TYPE.SEGMENT, 'parent-id', LIST_FILTER_MODE.EXCLUSION, segment.id)) + ).rejects.toThrow(`List ${segment.id} does not belong to owner parent-id.`); + }); + + it('rejects an inclusion URL for a nested segment list', async () => { + segmentsDataService.getSegmentById.mockReturnValue( + of({ segment: { id: 'parent-id', name: 'Parent segment', subSegments: [segment] } }) + ); + + await expect( + firstValueFrom(service.fetchOwner(LIST_OWNER_TYPE.SEGMENT, 'parent-id', LIST_FILTER_MODE.INCLUSION, segment.id)) + ).rejects.toThrow(`List ${segment.id} does not belong to owner parent-id for ${LIST_FILTER_MODE.INCLUSION}.`); + }); + + it('rejects a Segment-backed experiment list before loading its members', async () => { + experimentDataService.getExperimentById.mockReturnValue( + of({ + id: 'experiment-id', + name: 'Test experiment', + state: EXPERIMENT_STATE.ENROLLING, + experimentSegmentInclusion: [{ segment: segmentBackedList }], + experimentSegmentExclusion: [], + }) + ); + + await expect( + firstValueFrom( + service.fetchListDetails( + LIST_OWNER_TYPE.EXPERIMENT, + 'experiment-id', + LIST_FILTER_MODE.INCLUSION, + segmentBackedList.id + ) + ) + ).rejects.toThrow(`Segment-backed list ${segmentBackedList.id} cannot be opened in List Details.`); + expect(segmentsDataService.fetchSegmentWithMembersById).not.toHaveBeenCalled(); + }); + + it('rejects a Segment-backed feature flag list before loading its members', async () => { + featureFlagsDataService.fetchFeatureFlagById.mockReturnValue( + of({ + id: 'flag-id', + name: 'Test flag', + featureFlagSegmentInclusion: [{ segment: segmentBackedList, enabled: true, listType: 'Segment' }], + featureFlagSegmentExclusion: [], + }) + ); + + await expect( + firstValueFrom( + service.fetchListDetails( + LIST_OWNER_TYPE.FEATURE_FLAG, + 'flag-id', + LIST_FILTER_MODE.INCLUSION, + segmentBackedList.id + ) + ) + ).rejects.toThrow(`Segment-backed list ${segmentBackedList.id} cannot be opened in List Details.`); + expect(segmentsDataService.fetchSegmentWithMembersById).not.toHaveBeenCalled(); + }); + + it('rejects a Segment-backed nested list before loading its members', async () => { + segmentsDataService.getSegmentById.mockReturnValue( + of({ segment: { id: 'parent-id', name: 'Parent segment', subSegments: [segmentBackedList] } }) + ); + + await expect( + firstValueFrom( + service.fetchListDetails(LIST_OWNER_TYPE.SEGMENT, 'parent-id', LIST_FILTER_MODE.EXCLUSION, segmentBackedList.id) + ) + ).rejects.toThrow(`Segment-backed list ${segmentBackedList.id} cannot be opened in List Details.`); + expect(segmentsDataService.fetchSegmentWithMembersById).not.toHaveBeenCalled(); + }); + + it('rejects a legacy Segment-backed list inferred from its subsegment relationship', async () => { + const legacySegmentBackedList = { ...segmentBackedList, listType: undefined }; + experimentDataService.getExperimentById.mockReturnValue( + of({ + id: 'experiment-id', + name: 'Test experiment', + state: EXPERIMENT_STATE.ENROLLING, + experimentSegmentInclusion: [{ segment: legacySegmentBackedList }], + experimentSegmentExclusion: [], + }) + ); + segmentsDataService.fetchSegmentWithMembersById.mockReturnValue(of(legacySegmentBackedList)); + + await expect( + firstValueFrom( + service.fetchListDetails( + LIST_OWNER_TYPE.EXPERIMENT, + 'experiment-id', + LIST_FILTER_MODE.INCLUSION, + legacySegmentBackedList.id + ) + ) + ).rejects.toThrow(`Segment-backed list ${legacySegmentBackedList.id} cannot be opened in List Details.`); + }); + + it('infers an Individual type for a legacy Segment-owned list with only individual members', async () => { + const legacyIndividualList = { + ...segment, + listType: undefined, + individualForSegment: [{ userId: 'student-1', segmentId: segment.id }], + groupForSegment: [], + subSegments: [], + }; + segmentsDataService.getSegmentById.mockReturnValue( + of({ segment: { id: 'parent-id', name: 'Parent segment', subSegments: [legacyIndividualList] } }) + ); + segmentsDataService.fetchSegmentWithMembersById.mockReturnValue(of(legacyIndividualList)); + + const result = await firstValueFrom( + service.fetchListDetails(LIST_OWNER_TYPE.SEGMENT, 'parent-id', LIST_FILTER_MODE.EXCLUSION, segment.id) + ); + + expect(result.list.listType).toBe('Individual'); + expect(result.list.individualForSegment).toEqual(legacyIndividualList.individualForSegment); + }); + + it('infers the common group type for a legacy Segment-owned list with only same-type group members', async () => { + const legacyGroupList = { + ...segment, + listType: undefined, + individualForSegment: [], + groupForSegment: [ + { groupId: 'school-1', type: 'schoolId', segmentId: segment.id }, + { groupId: 'school-2', type: 'schoolId', segmentId: segment.id }, + ], + subSegments: [], + }; + segmentsDataService.getSegmentById.mockReturnValue( + of({ segment: { id: 'parent-id', name: 'Parent segment', subSegments: [legacyGroupList] } }) + ); + segmentsDataService.fetchSegmentWithMembersById.mockReturnValue(of(legacyGroupList)); + + const result = await firstValueFrom( + service.fetchListDetails(LIST_OWNER_TYPE.SEGMENT, 'parent-id', LIST_FILTER_MODE.EXCLUSION, segment.id) + ); + + expect(result.list.listType).toBe('schoolId'); + expect(result.list.groupForSegment).toEqual(legacyGroupList.groupForSegment); + }); + + it.each([ + { + name: 'empty', + individualForSegment: [], + groupForSegment: [], + }, + { + name: 'mixed-member', + individualForSegment: [{ userId: 'student-1', segmentId: segment.id }], + groupForSegment: [{ groupId: 'school-1', type: 'schoolId', segmentId: segment.id }], + }, + { + name: 'mixed-group-type', + individualForSegment: [], + groupForSegment: [ + { groupId: 'school-1', type: 'schoolId', segmentId: segment.id }, + { groupId: 'class-1', type: 'classId', segmentId: segment.id }, + ], + }, + ])('does not infer a type for an ambiguous $name legacy Segment-owned list', async (members) => { + const ambiguousList = { + ...segment, + listType: undefined, + ...members, + subSegments: [], + }; + segmentsDataService.getSegmentById.mockReturnValue( + of({ segment: { id: 'parent-id', name: 'Parent segment', subSegments: [ambiguousList] } }) + ); + segmentsDataService.fetchSegmentWithMembersById.mockReturnValue(of(ambiguousList)); + + await expect( + firstValueFrom( + service.fetchListDetails(LIST_OWNER_TYPE.SEGMENT, 'parent-id', LIST_FILTER_MODE.EXCLUSION, segment.id) + ) + ).rejects.toThrow(`List type for ${segment.id} cannot be determined from its members.`); + }); + + 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(); + }); + }); + + it('uses the experiment inclusion delete endpoint', (done) => { + experimentDataService.deleteInclusionList.mockReturnValue(of(undefined)); + + service + .deleteList(LIST_OWNER_TYPE.EXPERIMENT, LIST_FILTER_MODE.INCLUSION, 'experiment-id', segment.id) + .subscribe(() => { + expect(experimentDataService.deleteInclusionList).toHaveBeenCalledWith(segment.id); + done(); + }); + }); + + it('uses the feature flag exclusion delete endpoint', (done) => { + featureFlagsDataService.deleteExclusionList.mockReturnValue(of(undefined)); + + service + .deleteList(LIST_OWNER_TYPE.FEATURE_FLAG, LIST_FILTER_MODE.EXCLUSION, 'flag-id', segment.id) + .subscribe(() => { + expect(featureFlagsDataService.deleteExclusionList).toHaveBeenCalledWith(segment.id); + 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..08a00c7f0c --- /dev/null +++ b/packages/frontend/projects/upgrade/src/app/core/segments/list-details.data.service.ts @@ -0,0 +1,245 @@ +import { Injectable } from '@angular/core'; +import { Observable, map, switchMap } from 'rxjs'; +import { EXPERIMENT_STATE, LIST_FILTER_MODE, STANDARD_LIST_TYPE, normalizeStandardListType } from 'upgrade_types'; +import { ExperimentDataService } from '../experiments/experiments.data.service'; +import { Experiment } from '../experiments/store/experiments.model'; +import { FeatureFlagsDataService } from '../feature-flags/feature-flags.data.service'; +import { FeatureFlag } from '../feature-flags/store/feature-flags.model'; +import { SegmentsDataService } from './segments.data.service'; +import { + EditPrivateSegmentListDetails, + EditPrivateSegmentListRequest, + ExperimentSegmentListRequest, + LIST_OWNER_TYPE, + ListDetailsOwner, + ListDetailsOwnerRestriction, + Segment, +} from './store/segments.model'; + +@Injectable({ providedIn: 'root' }) +export class ListDetailsDataService { + constructor( + private experimentDataService: ExperimentDataService, + private featureFlagsDataService: FeatureFlagsDataService, + private segmentsDataService: SegmentsDataService + ) {} + + fetchListDetails( + ownerType: LIST_OWNER_TYPE, + ownerId: string, + filterMode: LIST_FILTER_MODE, + listId: string + ): Observable<{ list: Segment; owner: ListDetailsOwner }> { + return this.fetchOwner(ownerType, ownerId, filterMode, listId).pipe( + switchMap((owner) => { + this.requireDirectValueList(owner.listType, listId); + return this.segmentsDataService.fetchSegmentWithMembersById(listId).pipe( + map((list) => { + const declaredListType = normalizeStandardListType(list.listType) || owner.listType; + const resolvedListType = this.resolveListType(declaredListType, list); + + this.requireDirectValueList(resolvedListType, listId, list.subSegments); + if (!resolvedListType) { + throw new Error(`List type for ${listId} cannot be determined from its members.`); + } + + return { list: { ...list, listType: resolvedListType }, owner }; + }) + ); + }) + ); + } + + 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) => { + const lists = + filterMode === LIST_FILTER_MODE.INCLUSION + ? experiment.experimentSegmentInclusion + : experiment.experimentSegmentExclusion; + const list = this.requireOwnedList( + lists?.find((entry) => entry.segment?.id === listId), + listId, + ownerId + ); + return { + id: experiment.id, + name: experiment.name, + type: ownerType, + // The experiment response carries the inferred list type for legacy lists + // whose own segment row predates the listType column. + listType: list.segment?.listType, + restriction: this.getExperimentListRestriction(experiment.state), + }; + }) + ); + 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; + const list = this.requireOwnedList( + lists?.find((entry) => entry.segment.id === listId), + listId, + ownerId + ); + return { + id: featureFlag.id, + name: featureFlag.name, + type: ownerType, + listEnabled: list?.enabled, + listType: list?.listType, + }; + }) + ); + case LIST_OWNER_TYPE.SEGMENT: + return this.segmentsDataService.getSegmentById(ownerId).pipe( + map((response: { segment: Segment }) => { + if (filterMode !== LIST_FILTER_MODE.EXCLUSION) { + throw new Error(`List ${listId} does not belong to owner ${ownerId} for ${filterMode}.`); + } + const list = this.requireOwnedList( + response.segment.subSegments?.find((subSegment) => subSegment.id === listId), + listId, + ownerId + ); + return { + id: response.segment.id, + name: response.segment.name, + type: ownerType, + segmentType: response.segment.type, + listType: list.listType, + }; + }) + ); + } + } + + private getExperimentListRestriction(state: EXPERIMENT_STATE): ListDetailsOwnerRestriction { + // Match the Experiment Details section-card behavior: completed actions remain + // visible but disabled, while archived actions are hidden. + if (state === EXPERIMENT_STATE.ARCHIVED) { + return { + isDisabled: true, + shouldHideActions: true, + tooltipKey: 'experiments.details.restrictions.experiment-archived.text', + }; + } + + if (state === EXPERIMENT_STATE.COMPLETED) { + return { + isDisabled: true, + tooltipKey: 'experiments.details.restrictions.experiment-completed.text', + }; + } + + return { isDisabled: false }; + } + + updateList( + ownerType: LIST_OWNER_TYPE, + filterMode: LIST_FILTER_MODE, + 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); + } + + private requireOwnedList(list: T | undefined, listId: string, ownerId: string): T { + if (!list) { + throw new Error(`List ${listId} does not belong to owner ${ownerId}.`); + } + return list; + } + + private requireDirectValueList(listType: string | undefined, listId: string, subSegments?: Segment[]): void { + const normalizedListType = normalizeStandardListType(listType); + if (normalizedListType === STANDARD_LIST_TYPE.SEGMENT || (!normalizedListType && (subSegments?.length ?? 0) > 0)) { + throw new Error(`Segment-backed list ${listId} cannot be opened in List Details.`); + } + } + + private resolveListType(listType: string | undefined, list: Segment): string { + const normalizedListType = normalizeStandardListType(listType); + if (normalizedListType) { + return normalizedListType; + } + + const individuals = list.individualForSegment ?? []; + const groups = list.groupForSegment ?? []; + const subSegments = list.subSegments ?? []; + + // Legacy rows predate segment.listType. Match the existing backend compatibility + // rule and infer only member sets that identify one unambiguous list type. + if (individuals.length > 0 && groups.length === 0 && subSegments.length === 0) { + return STANDARD_LIST_TYPE.INDIVIDUAL; + } + + if (individuals.length === 0 && groups.length > 0 && subSegments.length === 0) { + const groupType = groups[0].type; + if (groups.every((group) => group.type !== 'All' && group.type === groupType)) { + return groupType; + } + } + + if (individuals.length === 0 && groups.length === 0 && subSegments.length > 0) { + return STANDARD_LIST_TYPE.SEGMENT; + } + + return ''; + } +} diff --git a/packages/frontend/projects/upgrade/src/app/core/segments/list-details.utils.spec.ts b/packages/frontend/projects/upgrade/src/app/core/segments/list-details.utils.spec.ts new file mode 100644 index 0000000000..9a6bd68a44 --- /dev/null +++ b/packages/frontend/projects/upgrade/src/app/core/segments/list-details.utils.spec.ts @@ -0,0 +1,19 @@ +import { LIST_FILTER_MODE } from 'upgrade_types'; +import { parseListFilterMode } from './list-details.utils'; + +describe('list details utils', () => { + describe('#parseListFilterMode', () => { + it.each([ + ['inclusion', LIST_FILTER_MODE.INCLUSION], + ['INCLUSION', LIST_FILTER_MODE.INCLUSION], + ['exclusion', LIST_FILTER_MODE.EXCLUSION], + ['EXCLUSION', LIST_FILTER_MODE.EXCLUSION], + ])('should parse %s', (filterMode, expected) => { + expect(parseListFilterMode(filterMode)).toBe(expected); + }); + + it.each([undefined, null, '', 'typo'])('should reject %s', (filterMode) => { + expect(parseListFilterMode(filterMode)).toBeUndefined(); + }); + }); +}); diff --git a/packages/frontend/projects/upgrade/src/app/core/segments/list-details.utils.ts b/packages/frontend/projects/upgrade/src/app/core/segments/list-details.utils.ts new file mode 100644 index 0000000000..d40c253be2 --- /dev/null +++ b/packages/frontend/projects/upgrade/src/app/core/segments/list-details.utils.ts @@ -0,0 +1,15 @@ +import { LIST_FILTER_MODE } from 'upgrade_types'; + +export function parseListFilterMode(filterMode: string | null | undefined): LIST_FILTER_MODE | undefined { + const normalizedFilterMode = filterMode?.toLowerCase(); + + if (normalizedFilterMode === LIST_FILTER_MODE.INCLUSION) { + return LIST_FILTER_MODE.INCLUSION; + } + + if (normalizedFilterMode === LIST_FILTER_MODE.EXCLUSION) { + return LIST_FILTER_MODE.EXCLUSION; + } + + return undefined; +} diff --git a/packages/frontend/projects/upgrade/src/app/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..2ff40f106e --- /dev/null +++ b/packages/frontend/projects/upgrade/src/app/core/segments/list-values.utils.spec.ts @@ -0,0 +1,68 @@ +import { + containsListValueSeparator, + mergeUniqueListValues, + parseSingleColumnCSV, + splitListValues, +} from './list-values.utils'; + +describe('list values utilities', () => { + describe('splitListValues', () => { + it('splits pasted values on commas and new lines while preserving internal whitespace', () => { + expect(splitListValues('one, two three\nhello world\r\nfive')).toEqual([ + 'one', + 'two three', + 'hello world', + 'five', + ]); + }); + + it('trims values and drops empty entries', () => { + expect(splitListValues(' one, ,\n two ')).toEqual(['one', 'two']); + }); + }); + + describe('containsListValueSeparator', () => { + it('flags values that the add/import pipelines would split or reject', () => { + expect(containsListValueSeparator('schoolA,schoolB')).toBe(true); + expect(containsListValueSeparator('school\nA')).toBe(true); + expect(containsListValueSeparator('school\tA')).toBe(true); + expect(containsListValueSeparator('school-A_1')).toBe(false); + }); + }); + + describe('mergeUniqueListValues', () => { + it('keeps existing order and reports duplicate values', () => { + expect(mergeUniqueListValues(['one', 'two'], ['two', 'three', 'three'])).toEqual({ + values: ['one', 'two', 'three'], + addedValues: ['three'], + duplicateValues: ['two', 'three'], + }); + }); + }); + + describe('parseSingleColumnCSV', () => { + it('parses a single-column CSV without a header', () => { + expect(parseSingleColumnCSV('one\ntwo\r\nthree\rfour')).toEqual(['one', 'two', 'three', 'four']); + }); + + it('preserves duplicate rows for post-operation reporting', () => { + expect(parseSingleColumnCSV('one\none\ntwo')).toEqual(['one', 'one', 'two']); + }); + + it('preserves internal whitespace', () => { + expect(parseSingleColumnCSV('hello world')).toEqual(['hello world']); + }); + + it('rejects multiple columns', () => { + expect(() => parseSingleColumnCSV('school,one')).toThrow('CSV should contain only one column'); + }); + + it.each(['school\tone', '\tschool'])('rejects tab characters before normalizing values', (content) => { + expect(() => parseSingleColumnCSV(content)).toThrow('CSV values cannot contain tabs'); + }); + + it('rejects an empty CSV file', () => { + expect(() => parseSingleColumnCSV('')).toThrow('CSV file is empty'); + }); + }); +}); 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..bf6ff27921 --- /dev/null +++ b/packages/frontend/projects/upgrade/src/app/core/segments/list-values.utils.ts @@ -0,0 +1,63 @@ +export interface MergeListValuesResult { + values: string[]; + addedValues: string[]; + duplicateValues: string[]; +} + +const VALUE_SEPARATORS = /[,\r\n]+/; + +export function containsTabCharacter(value: string): boolean { + return value.includes('\t'); +} + +export function splitListValues(rawValue: string): string[] { + return rawValue + .split(VALUE_SEPARATORS) + .map((value) => value.trim()) + .filter(Boolean); +} + +/** True when a single value contains a delimiter or a tab that the backend cannot preserve. */ +export function containsListValueSeparator(value: string): boolean { + return VALUE_SEPARATORS.test(value) || containsTabCharacter(value); +} + +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 parseSingleColumnCSV(content: string): string[] { + if (containsTabCharacter(content)) throw new Error('CSV values cannot contain tabs'); + + const lines = content + .split(/\r\n|\n|\r/) + .map((line) => line.trim()) + .filter(Boolean); + + if (lines.length === 0) throw new Error('CSV file is empty'); + if (lines.some((line) => line.includes(','))) throw new Error('CSV should contain only one column'); + return lines; +} diff --git a/packages/frontend/projects/upgrade/src/app/core/segments/store/segments.effects.spec.ts b/packages/frontend/projects/upgrade/src/app/core/segments/store/segments.effects.spec.ts index 16d76b10be..b497d9c708 100644 --- a/packages/frontend/projects/upgrade/src/app/core/segments/store/segments.effects.spec.ts +++ b/packages/frontend/projects/upgrade/src/app/core/segments/store/segments.effects.spec.ts @@ -1,9 +1,16 @@ import { fakeAsync, tick } from '@angular/core/testing'; import { ActionsSubject } from '@ngrx/store'; import { BehaviorSubject, of, throwError } from 'rxjs'; -import { SEGMENT_STATUS, SEGMENT_TYPE } from 'upgrade_types'; +import { LIST_FILTER_MODE, SEGMENT_STATUS, SEGMENT_TYPE } from 'upgrade_types'; import { SegmentsEffects } from './segments.effects'; -import { Segment, SegmentFile, SegmentInput, UpsertSegmentType } from './segments.model'; +import { + AddPrivateSegmentListRequest, + LIST_OPTION_TYPE, + Segment, + SegmentFile, + SegmentInput, + UpsertSegmentType, +} from './segments.model'; import { selectAllSegments } from './segments.selectors'; import * as SegmentsActions from './segments.actions'; import { CommonModalEventsService } from '../../../shared/services/common-modal-event.service'; @@ -167,6 +174,67 @@ describe('SegmentsEffects', () => { })); }); + describe('addSegmentList$', () => { + const parentSegmentId = 'parent-segment-id'; + const listId = 'list-id'; + const listResponse = { segment: { ...mockSegment, id: listId } } as any; + + const createListRequest = (listType: string): AddPrivateSegmentListRequest => ({ + id: parentSegmentId, + enabled: true, + listType, + segment: { + name: 'Test list', + description: '', + context: 'test', + type: SEGMENT_TYPE.PRIVATE, + userIds: [], + groups: [], + subSegmentIds: [], + listType, + }, + }); + + it('should navigate a direct-value list to its List Details page', fakeAsync(() => { + const list = createListRequest(LIST_OPTION_TYPE.INDIVIDUAL); + segmentsDataService.addSegmentList = jest.fn().mockReturnValue(of(listResponse)); + + const expectedAction = SegmentsActions.actionAddSegmentListSuccess({ listResponse }); + + service.addSegmentList$.subscribe((result) => { + expect(result).toEqual(expectedAction); + expect(router.navigate).toHaveBeenCalledWith([ + '/segments', + 'detail', + parentSegmentId, + 'list', + LIST_FILTER_MODE.EXCLUSION, + listId, + ]); + }); + + actions$.next(SegmentsActions.actionAddSegmentList({ list })); + + tick(0); + })); + + it('should keep a Segment-backed list on the owner page', fakeAsync(() => { + const list = createListRequest(LIST_OPTION_TYPE.SEGMENT); + segmentsDataService.addSegmentList = jest.fn().mockReturnValue(of(listResponse)); + + const expectedAction = SegmentsActions.actionAddSegmentListSuccess({ listResponse }); + + service.addSegmentList$.subscribe((result) => { + expect(result).toEqual(expectedAction); + expect(router.navigate).not.toHaveBeenCalled(); + }); + + actions$.next(SegmentsActions.actionAddSegmentList({ list })); + + tick(0); + })); + }); + describe('exportSegments$', () => { it('should do nothing if Segment is id', fakeAsync(() => { let neverEmitted = true; 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 88c9928312..382a2fc5f1 100644 --- a/packages/frontend/projects/upgrade/src/app/core/segments/store/segments.model.ts +++ b/packages/frontend/projects/upgrade/src/app/core/segments/store/segments.model.ts @@ -310,6 +310,31 @@ export enum LIST_OPTION_TYPE { SEGMENT = 'Segment', } +export enum LIST_OWNER_TYPE { + EXPERIMENT = 'experiment', + FEATURE_FLAG = 'featureFlag', + SEGMENT = 'segment', +} + +export interface ListDetailsOwnerRestriction { + isDisabled: boolean; + tooltipKey?: string; + shouldHideActions?: boolean; +} + +export interface ListDetailsOwner { + id: string; + name: string; + type: LIST_OWNER_TYPE; + segmentType?: SEGMENT_TYPE; + listEnabled?: boolean; + // Owner-side list type, used as a fallback when the list's own segment row predates + // the listType column (flag join rows store it; experiment responses infer it). + listType?: string; + // Mirrors the owner details page's disabled/hidden action behavior. + restriction?: ListDetailsOwnerRestriction; +} + 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..21bc4856b1 --- /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,22 @@ + +
+ + Value + + @if (valueControl.hasError('required')) { + Value is required. + } @else if (valueControl.hasError('separator')) { + Value cannot contain commas or tabs. + } @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..64e969da88 --- /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,52 @@ +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'; +import { containsListValueSeparator } from '../../../../../core/segments/list-values.utils'; + +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(); + if (!value) { + return { required: true }; + } + // The add/import pipelines split on these characters, so a value containing them + // could not round-trip through paste or CSV export/import. + if (containsListValueSeparator(value)) { + return { separator: true }; + } + return value !== this.data.value && this.data.existingValues.includes(value) ? { duplicate: true } : null; + } + + 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..9356646a68 --- /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,59 @@ + +
+ @if (data.importOnly) { @if (!fileName || errorMessage) { +
+ +

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

+
+ } @else { +
+ + {{ fileName }} — {{ importedValues.length }} {{ importedValues.length === 1 ? 'value' : 'values' }} + + +
+ } +
+ + + Append to existing values + Replace existing values + +
+ } @else { + + Values + + Separate values with commas or new lines. + @if (rawValuesControl.hasError('tab')) { + Values cannot contain tabs. + } + + } +
+
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..e029146339 --- /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,41 @@ +.values-form { + display: flex; + flex-direction: column; + gap: 16px; + + mat-form-field { + width: 100%; + } +} + +.drag-drop-container { + display: flex; + flex-direction: column; + row-gap: 2px; + + .import-message { + margin: 0; + text-indent: 18px; + } +} + +.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; + } +} + +.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..4db344bd3a --- /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,169 @@ +import { ChangeDetectionStrategy, ChangeDetectorRef, Component, Inject, OnDestroy } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormControl, FormsModule, ReactiveFormsModule } from '@angular/forms'; +import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatInputModule } from '@angular/material/input'; +import { MatRadioModule } from '@angular/material/radio'; +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 { + containsTabCharacter, + parseSingleColumnCSV, + splitListValues, +} from '../../../../../core/segments/list-values.utils'; + +export enum LIST_VALUES_UPDATE_MODE { + APPEND = 'append', + REPLACE = 'replace', +} + +export interface UpsertListValuesModalData { + importOnly?: boolean; +} + +export interface UpsertListValuesModalResult { + values: string[]; + mode: LIST_VALUES_UPDATE_MODE; + fileName?: string; +} + +@Component({ + selector: 'app-upsert-list-values-modal', + imports: [ + CommonModule, + FormsModule, + ReactiveFormsModule, + MatFormFieldModule, + 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 implements OnDestroy { + readonly rawValuesControl = new FormControl('', { + nonNullable: true, + validators: [(control) => (containsTabCharacter(control.value) ? { tab: true } : null)], + }); + importedValues: string[] = []; + fileName = ''; + errorMessage = ''; + updateMode = LIST_VALUES_UPDATE_MODE.APPEND; + readonly UPDATE_MODE = LIST_VALUES_UPDATE_MODE; + readonly FILE_TYPE = FILE_TYPE; + private activeFileReader?: FileReader; + + constructor( + @Inject(MAT_DIALOG_DATA) public data: UpsertListValuesModalData, + 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.rawValuesControl.value); + } + + get primaryActionLabel(): string { + return this.data.importOnly ? 'Import' : 'Add'; + } + + get hasUnsupportedTab(): boolean { + return !this.data.importOnly && this.rawValuesControl.hasError('tab'); + } + + get isPrimaryActionDisabled(): boolean { + return ( + this.values.length === 0 || + this.hasUnsupportedTab || + !!this.errorMessage || + (this.data.importOnly && !this.fileName) + ); + } + + onFilesSelected(files: File[]): void { + this.cancelActiveFileRead(); + + const file = files[0]; + this.errorMessage = ''; + this.importedValues = []; + this.fileName = file?.name ?? ''; + + if (!file) { + return; + } + + const reader = new FileReader(); + this.activeFileReader = reader; + reader.onload = () => { + if (this.activeFileReader !== reader) { + return; + } + + try { + this.importedValues = parseSingleColumnCSV(String(reader.result ?? '')); + } catch (error) { + this.errorMessage = error instanceof Error ? error.message : 'Unable to read CSV file'; + } finally { + this.activeFileReader = undefined; + this.changeDetectorRef.markForCheck(); + } + }; + reader.onerror = () => { + if (this.activeFileReader !== reader) { + return; + } + + this.activeFileReader = undefined; + this.errorMessage = 'Unable to read CSV file'; + this.changeDetectorRef.markForCheck(); + }; + reader.readAsText(file); + } + + clearImportedFile(): void { + this.cancelActiveFileRead(); + this.fileName = ''; + this.importedValues = []; + this.errorMessage = ''; + } + + ngOnDestroy(): void { + this.cancelActiveFileRead(); + } + + submit(): void { + if (this.isPrimaryActionDisabled) { + return; + } + + this.dialogRef.close({ values: this.values, mode: this.updateMode, fileName: this.fileName }); + } + + private cancelActiveFileRead(): void { + const reader = this.activeFileReader; + this.activeFileReader = undefined; + + if (!reader) { + return; + } + + reader.onload = null; + reader.onerror = null; + + if (reader.readyState === FileReader.LOADING) { + reader.abort(); + } + } +} 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 21b95e0402..a8645f7d19 100644 --- a/packages/frontend/projects/upgrade/src/app/features/dashboard/segments/modals/upsert-private-segment-list-modal/upsert-private-segment-list-modal.component.ts +++ b/packages/frontend/projects/upgrade/src/app/features/dashboard/segments/modals/upsert-private-segment-list-modal/upsert-private-segment-list-modal.component.ts @@ -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'; @@ -47,11 +46,10 @@ import { Subscription, timer, } from 'rxjs'; -import { SEGMENT_TYPE } from '../../../../../../../../../../types/src'; +import { SEGMENT_TYPE } from 'upgrade_types'; import isEqual from 'lodash.isequal'; import { FeatureFlagsService } from '../../../../../core/feature-flags/feature-flags.service'; import { CommonModalConfig } from '@shared-component-lib/common-modal/common-modal.types'; -import { CommonTagInputType } from '../../../../../core/feature-flags/store/feature-flags.model'; import { SharedModule } from '../../../../../shared/shared.module'; import { getSegmentListEditData, SegmentListEditData } from '../../../../../core/segments/segment-list.helper'; @@ -63,7 +61,6 @@ import { getSegmentListEditData, SegmentListEditData } from '../../../../../core MatFormFieldModule, MatInputModule, MatAutocompleteModule, - CommonTagsInputComponent, CommonModule, ReactiveFormsModule, TranslateModule, @@ -97,9 +94,6 @@ export class UpsertPrivateSegmentListModalComponent { isSegmentsListTypeDisabled$: Observable; privateSegmentListForm: FormGroup; - CommonTagInputType = CommonTagInputType; - forceValidation = false; - constructor( @Inject(MAT_DIALOG_DATA) public config: CommonModalConfig, @@ -108,7 +102,6 @@ export class UpsertPrivateSegmentListModalComponent { private segmentsService: SegmentsService, private experimentService: ExperimentService, private featureFlagService: FeatureFlagsService, - private commonExportHelpersService: CommonExportHelpersService, private changeDetectorRef: ChangeDetectorRef, public dialogRef: MatDialogRef ) {} @@ -154,6 +147,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; @@ -194,15 +197,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; } @@ -214,6 +209,7 @@ export class UpsertPrivateSegmentListModalComponent { const editData = getSegmentListEditData(sourceList.listType, sourceList.segment); this.applyEditFormValues(editData, 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 (editData.membersNeedFetch) { @@ -287,8 +283,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()); } @@ -347,7 +343,6 @@ export class UpsertPrivateSegmentListModalComponent { this.segmentObjectValidator(), ]); } else { - CommonFormHelpersService.setFieldValidators(this.privateSegmentListForm, valuesField, [Validators.required]); CommonFormHelpersService.setFieldValidators(this.privateSegmentListForm, nameField, [Validators.required]); } } @@ -358,7 +353,6 @@ export class UpsertPrivateSegmentListModalComponent { } onPrimaryActionBtnClicked(): void { - this.forceValidation = true; if (this.privateSegmentListForm.valid) { this.sendRequest(this.config.params.action); } else { @@ -368,7 +362,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, @@ -381,7 +375,7 @@ export class UpsertPrivateSegmentListModalComponent { const listRequest: PrivateSegmentListRequest = { id: this.config.params.id, - enabled: this.config.params.sourceList?.enabled || isExcludeList, // Maintain existing status for edits, default to false for new include lists, true for all exclude lists + enabled: this.config.params.sourceList?.enabled ?? isExcludeList, // Maintain existing status for edits, default to false for new include lists, true for all exclude lists listType, segment: { ...list, listType }, }; @@ -513,14 +507,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..527f77779d --- /dev/null +++ b/packages/frontend/projects/upgrade/src/app/features/dashboard/segments/pages/list-details-page/list-details-page.component.html @@ -0,0 +1,151 @@ + + + +
+ @if (isLoading) { + + } @if (list && owner) { @let restrictionTooltip = ownerRestriction.tooltipKey | translate; + + + + + + + + + + +
+ + + @if (values.length) { + + } +
+ + + + @if (isValuesSectionExpanded) { +
+ @if (isSaving) { + + } + + + + + + + + + + + + + + + + +
Value{{ row.value }} + @if (canShowMutationActions) { Actions } + + @if (canShowMutationActions) { +
+ +
+
+ +
+ } +
+ {{ values.length ? 'No values match your search.' : 'No values yet. Add values or import a CSV.' }} +
+ @if (dataSource.filteredData.length > valuesPageSize) { + + } +
+ } +
+
+ } +
+
diff --git a/packages/frontend/projects/upgrade/src/app/features/dashboard/segments/pages/list-details-page/list-details-page.component.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..c92738326b --- /dev/null +++ b/packages/frontend/projects/upgrade/src/app/features/dashboard/segments/pages/list-details-page/list-details-page.component.ts @@ -0,0 +1,604 @@ +import { CommonModule } from '@angular/common'; +import { + ChangeDetectionStrategy, + ChangeDetectorRef, + Component, + ErrorHandler, + OnDestroy, + OnInit, + ViewChild, +} from '@angular/core'; +import { MatButtonModule } from '@angular/material/button'; +import { MatDialog } from '@angular/material/dialog'; +import { MatIconModule } from '@angular/material/icon'; +import { MatPaginator, MatPaginatorModule } from '@angular/material/paginator'; +import { MatProgressBarModule } from '@angular/material/progress-bar'; +import { MatTableDataSource, MatTableModule } from '@angular/material/table'; +import { MatTooltipModule } from '@angular/material/tooltip'; +import { ActivatedRoute, Router } from '@angular/router'; +import { TranslateModule } from '@ngx-translate/core'; +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, 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 { parseListFilterMode } from '../../../../../core/segments/list-details.utils'; +import { + EditPrivateSegmentListDetails, + LIST_OPTION_TYPE, + LIST_OWNER_TYPE, + ListDetailsOwner, + ListDetailsOwnerRestriction, + 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 { 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, + MatPaginatorModule, + MatProgressBarModule, + MatTableModule, + MatTooltipModule, + TranslateModule, + ], + 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 valuesPageSize = 10; + readonly dataSource = new MatTableDataSource([]); + ownerType: LIST_OWNER_TYPE; + ownerId = ''; + listId = ''; + filterMode: LIST_FILTER_MODE; + owner: ListDetailsOwner; + list: Segment; + listType = ''; + listEnabled = true; + values: string[] = []; + valuesSearchString = ''; + metadataMenuButtonItems: IMenuButtonItem[] = []; + valuesMenuButtonItems: IMenuButtonItem[] = []; + showMetadataMenuButton = false; + isValuesMenuDisabled = true; + isLoading = true; + isSaving = false; + ownerRestriction: ListDetailsOwnerRestriction = { isDisabled: false }; + areSectionCardsExpanded = true; + isValuesSectionExpanded = true; + + private hasUpdatePermission = false; + private subscriptions = new Subscription(); + + @ViewChild(MatPaginator) set paginator(paginator: MatPaginator | undefined) { + this.dataSource.paginator = paginator ?? null; + } + + constructor( + private route: ActivatedRoute, + private router: Router, + private listDetailsDataService: ListDetailsDataService, + private dialog: MatDialog, + private dialogService: DialogService, + private authService: AuthService, + private notificationService: NotificationService, + private commonExportHelpersService: CommonExportHelpersService, + private changeDetectorRef: ChangeDetectorRef, + private errorHandler: ErrorHandler + ) { + 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') ?? ''; + const rawFilterMode = this.route.snapshot.paramMap.get('filterMode'); + const filterMode = parseListFilterMode(rawFilterMode); + if (!filterMode) { + this.handleLoadError(new Error(`Invalid list filter mode: ${rawFilterMode ?? ''}`)); + return; + } + this.filterMode = filterMode; + + this.subscriptions.add( + // List management is gated on segment permissions across all owner pages (see the + // inclusion/exclusion/lists section cards), so this page must match. + this.authService.userPermissions$.subscribe((permissions) => { + this.hasUpdatePermission = !!permissions?.segments?.update; + 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 typeLabel = + this.listType.toLowerCase() === LIST_OPTION_TYPE.INDIVIDUAL.toLowerCase() + ? LIST_OPTION_TYPE.INDIVIDUAL + : `Group: ${this.listType}`; + // Lists of a regular segment are plain member lists, not include/exclude lists. + if (this.isPlainSegmentList) { + return typeLabel; + } + const filterLabel = this.filterMode === LIST_FILTER_MODE.INCLUSION ? 'Include' : 'Exclude'; + return `${filterLabel} · ${typeLabel}`; + } + + get listOverviewDetails(): KeyValueFormat { + return { + Description: this.list.description ?? '', + }; + } + + get canManage(): boolean { + return this.canShowMutationActions && !this.ownerRestriction.isDisabled; + } + + get canShowMutationActions(): boolean { + return this.hasUpdatePermission && !this.ownerRestriction.shouldHideActions; + } + + get showValuesMenuButton(): boolean { + return this.canShowMutationActions || !this.isValuesMenuDisabled; + } + + private get isPlainSegmentList(): boolean { + return this.ownerType === LIST_OWNER_TYPE.SEGMENT && this.owner?.segmentType !== SEGMENT_TYPE.GLOBAL_EXCLUDE; + } + + loadDetails(): void { + if (!this.ownerId || !this.listId) { + this.handleLoadError(new Error('List owner ID and list ID are required.')); + return; + } + + this.isLoading = true; + this.changeDetectorRef.markForCheck(); + this.subscriptions.add( + this.listDetailsDataService + .fetchListDetails(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.ownerRestriction = owner.restriction ?? { isDisabled: false }; + this.listType = list.listType ?? owner.listType ?? ''; + this.listEnabled = owner.listEnabled ?? this.filterMode === LIST_FILTER_MODE.EXCLUSION; + this.setValues(this.determineValues(list)); + this.updateMetadataMenuButtonItems(); + this.changeDetectorRef.markForCheck(); + }, + error: (error) => this.handleLoadError(error), + }) + ); + } + + private handleLoadError(error: unknown): void { + this.isLoading = false; + this.changeDetectorRef.markForCheck(); + this.errorHandler.handleError(error); + } + + search(searchParams: CommonSearchWidgetSearchParams): void { + this.valuesSearchString = searchParams.searchString; + this.dataSource.filter = this.valuesSearchString.trim().toLowerCase(); + this.dataSource.paginator?.firstPage(); + } + + openAddValuesModal(): void { + const dialogRef = this.dialog.open(UpsertListValuesModalComponent, { + data: { importOnly: false }, + 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 }, + width: ModalSize.STANDARD, + autoFocus: '.choose-file-btn', + 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, + autoFocus: 'input', + 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 { + // The edit modal saves a full member replacement keyed on the list type, so opening it + // without a known type could wipe the list's real members (same guard as saveValues). + if (!this.listType) { + this.notificationService.showError('Unable to edit this list because the list type is unknown.'); + return; + } + + const sourceList: ParticipantListTableRow = { + listType: this.listType, + segment: this.list, + 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.changeDetectorRef.markForCheck(); + 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.canManage, + label: `Delete ${actionTarget}`, + }, + ]; + this.showMetadataMenuButton = this.canShowMutationActions; + } + + private getMetadataActionTarget(): string { + if (this.isPlainSegmentList) { + 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.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; + } + + // Without a known list type the update payload can't be built safely; a full-replacement + // save with the wrong member kind would wipe the list's real members. + if (!this.listType) { + this.notificationService.showError('Unable to update list values because the list type is unknown.'); + return; + } + + const isIndividualList = this.listType.toLowerCase() === LIST_OPTION_TYPE.INDIVIDUAL.toLowerCase(); + const segment: EditPrivateSegmentListDetails = { + id: this.list.id, + name: this.list.name, + description: this.list.description ?? '', + context: this.list.context, + type: SEGMENT_TYPE.PRIVATE, + userIds: isIndividualList ? values : [], + groups: isIndividualList ? [] : values.map((groupId) => ({ groupId, type: this.listType })), + subSegmentIds: [], + listType: this.listType, + }; + + this.isSaving = true; + this.changeDetectorRef.markForCheck(); + this.listDetailsDataService + .updateList(this.ownerType, this.filterMode, this.ownerId, this.listEnabled, this.listType, segment) + .pipe( + finalize(() => { + this.isSaving = false; + this.changeDetectorRef.markForCheck(); + }) + ) + .subscribe({ + next: (updatedList) => { + // The update response re-fetches the segment with its member relations, so this + // keeps this.list (the metadata edit modal's full-replacement source) up to date. + this.list = { ...this.list, ...updatedList, listType: this.listType }; + this.setValues(this.determineValues(this.list)); + 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 8a92a76e61..eb113dacbb 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 { { + let component: CommonDetailsParticipantListTableComponent; + + beforeEach(() => { + component = new CommonDetailsParticipantListTableComponent(); + }); + + it('infers a legacy Segment-backed wrapper from its subsegment relationship', () => { + const rowData = { + listType: undefined, + segment: { + subSegments: [{ type: SEGMENT_TYPE.PUBLIC }], + } as Segment, + } as ParticipantListTableRow; + + expect(component.getFormattedListType(rowData)).toBe(MemberTypes.SEGMENT); + expect(component.isSegmentListType(rowData)).toBe(true); + expect(component.isDirectValueList(rowData)).toBe(false); + expect(component.isPublicSegment(rowData)).toBe(true); + }); + + it('keeps a list without a type or subsegments classified as a direct value list', () => { + const rowData = { + listType: undefined, + segment: { subSegments: [] } as Segment, + } as ParticipantListTableRow; + + expect(component.getFormattedListType(rowData)).toBe(''); + expect(component.isSegmentListType(rowData)).toBe(false); + expect(component.isDirectValueList(rowData)).toBe(true); + }); +}); diff --git a/packages/frontend/projects/upgrade/src/app/shared-standalone-component-lib/components/common-details-participant-list-table/common-details-participant-list-table.component.ts b/packages/frontend/projects/upgrade/src/app/shared-standalone-component-lib/components/common-details-participant-list-table/common-details-participant-list-table.component.ts index 6cc9f93fe2..df6f1adb13 100644 --- a/packages/frontend/projects/upgrade/src/app/shared-standalone-component-lib/components/common-details-participant-list-table/common-details-participant-list-table.component.ts +++ b/packages/frontend/projects/upgrade/src/app/shared-standalone-component-lib/components/common-details-participant-list-table/common-details-participant-list-table.component.ts @@ -52,6 +52,7 @@ import { SharedModule } from '../../../shared/shared.module'; }) export class CommonDetailsParticipantListTableComponent { @Input() tableType: LIST_FILTER_MODE; + @Input() listFilterMode?: LIST_FILTER_MODE; @Input() dataSource: any[]; @Input() noDataRowText: string; @Input() slideToggleDisabled?: boolean = false; @@ -109,17 +110,30 @@ export class CommonDetailsParticipantListTableComponent { } getFormattedListType(rowData: ParticipantListTableRow): string { - return normalizeStandardListType(rowData.listType); + return this.getResolvedListType(rowData); } isSegmentListType(rowData: ParticipantListTableRow): boolean { - return normalizeStandardListType(rowData.listType) === this.memberTypes.SEGMENT; + return this.getResolvedListType(rowData) === this.memberTypes.SEGMENT; } isPublicSegment(rowData: ParticipantListTableRow): boolean { return this.isSegmentListType(rowData) && rowData.segment?.subSegments?.[0]?.type === SEGMENT_TYPE.PUBLIC; } + isDirectValueList(rowData: ParticipantListTableRow): boolean { + return !this.isSegmentListType(rowData); + } + + get detailsFilterMode(): LIST_FILTER_MODE { + return this.listFilterMode ?? this.tableType; + } + + private getResolvedListType(rowData: ParticipantListTableRow): string { + const listType = normalizeStandardListType(rowData.listType); + return listType || (rowData.segment?.subSegments?.length ? this.memberTypes.SEGMENT : ''); + } + onSlideToggleChange(event: MatSlideToggleChange, rowData: ParticipantListTableRow): void { const slideToggleEvent = event.source; const action = slideToggleEvent.checked ? PARTICIPANT_LIST_ROW_ACTION.ENABLE : PARTICIPANT_LIST_ROW_ACTION.DISABLE; diff --git a/packages/frontend/projects/upgrade/src/app/shared-standalone-component-lib/components/common-import-container/common-import-container.component.html b/packages/frontend/projects/upgrade/src/app/shared-standalone-component-lib/components/common-import-container/common-import-container.component.html index a2bc0e02b1..a24f51e2a6 100644 --- a/packages/frontend/projects/upgrade/src/app/shared-standalone-component-lib/components/common-import-container/common-import-container.component.html +++ b/packages/frontend/projects/upgrade/src/app/shared-standalone-component-lib/components/common-import-container/common-import-container.component.html @@ -6,7 +6,7 @@ [ngClass]="{ 'drag-over': (isDragOver | async) }" >
- @if (fileType === FILE_TYPE.CSV) { + @if (fileType === FILE_TYPE.CSV && showCloseButton) { @@ -19,7 +19,7 @@ @if (importFailed) { - {{ 'feature-flags.upsert-list-modal.import-csv.error.message.text' | translate }} + {{ importFailedMessage || ('feature-flags.upsert-list-modal.import-csv.error.message.text' | translate) }} } diff --git a/packages/frontend/projects/upgrade/src/app/shared-standalone-component-lib/components/common-import-container/common-import-container.component.ts b/packages/frontend/projects/upgrade/src/app/shared-standalone-component-lib/components/common-import-container/common-import-container.component.ts index 238756a9b7..8bb15023cc 100644 --- a/packages/frontend/projects/upgrade/src/app/shared-standalone-component-lib/components/common-import-container/common-import-container.component.ts +++ b/packages/frontend/projects/upgrade/src/app/shared-standalone-component-lib/components/common-import-container/common-import-container.component.ts @@ -12,6 +12,8 @@ import { FILE_TYPE } from 'upgrade_types'; * The component accepts the following inputs: * - `fileType`: A string representing the accepted file type (e.g., '.json'). Only files with this extension can be selected or dropped. * - `buttonLabel`: A string representing the label text of the button. Defaults to 'Upload File'. + * - `importFailedMessage`: An optional specific error message. The existing generic message is used when omitted. + * - `showCloseButton`: Whether to show the CSV close button. Defaults to true. * * The component emits the following outputs: * - `closeButtonClick`: A mouse event when the close button is clicked (only used for CSV file type). @@ -38,6 +40,8 @@ export class CommonImportContainerComponent { @Input() fileType!: FILE_TYPE; @Input() buttonLabel!: string; @Input() importFailed = false; + @Input() importFailedMessage = ''; + @Input() showCloseButton = true; @Output() closeButtonClick = new EventEmitter(); @Output() filesSelected = new EventEmitter(); @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/frontend/projects/upgrade/src/app/shared-standalone-component-lib/components/common-tabbed-section-card-footer/common-tabbed-section-card-footer.component.html b/packages/frontend/projects/upgrade/src/app/shared-standalone-component-lib/components/common-tabbed-section-card-footer/common-tabbed-section-card-footer.component.html index 4ce8b2701e..fe7a94ff79 100644 --- a/packages/frontend/projects/upgrade/src/app/shared-standalone-component-lib/components/common-tabbed-section-card-footer/common-tabbed-section-card-footer.component.html +++ b/packages/frontend/projects/upgrade/src/app/shared-standalone-component-lib/components/common-tabbed-section-card-footer/common-tabbed-section-card-footer.component.html @@ -7,7 +7,10 @@ (selectedTabChange)="onSelectedTabChange($event)" class="new-tab-group" > - @for (tab of tabLabels; track tab) { + + @for (tab of tabLabels; track tab.label) { diff --git a/packages/frontend/projects/upgrade/src/app/shared-standalone-component-lib/components/common-tabbed-section-card-footer/common-tabbed-section-card-footer.component.spec.ts b/packages/frontend/projects/upgrade/src/app/shared-standalone-component-lib/components/common-tabbed-section-card-footer/common-tabbed-section-card-footer.component.spec.ts new file mode 100644 index 0000000000..98c155052d --- /dev/null +++ b/packages/frontend/projects/upgrade/src/app/shared-standalone-component-lib/components/common-tabbed-section-card-footer/common-tabbed-section-card-footer.component.spec.ts @@ -0,0 +1,86 @@ +import { Component } from '@angular/core'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { ActivatedRoute, Router } from '@angular/router'; +import { provideNoopAnimations } from '@angular/platform-browser/animations'; +import { CommonTabbedSectionCardFooterComponent } from './common-tabbed-section-card-footer.component'; + +@Component({ + imports: [CommonTabbedSectionCardFooterComponent], + template: ``, +}) +class TestHostComponent { + tabLabels: { label: string; disabled?: boolean }[] = [{ label: 'Lists' }, { label: 'Used By' }]; + onSelectedTabChange = jest.fn(); +} + +describe('CommonTabbedSectionCardFooterComponent', () => { + let fixture: ComponentFixture; + let host: TestHostComponent; + let mockRouter: { navigate: jest.Mock }; + + const mockRoute = { + snapshot: { + queryParamMap: { + get: jest.fn().mockReturnValue(null), + }, + }, + } as unknown as ActivatedRoute; + + const flushMicrotasks = async () => { + await fixture.whenStable(); + fixture.detectChanges(); + await fixture.whenStable(); + }; + + beforeEach(async () => { + mockRouter = { navigate: jest.fn() }; + + await TestBed.configureTestingModule({ + imports: [TestHostComponent], + providers: [ + provideNoopAnimations(), + { provide: Router, useValue: mockRouter }, + { provide: ActivatedRoute, useValue: mockRoute }, + ], + }).compileComponents(); + + fixture = TestBed.createComponent(TestHostComponent); + host = fixture.componentInstance; + fixture.detectChanges(); + await flushMicrotasks(); + mockRouter.navigate.mockClear(); + host.onSelectedTabChange.mockClear(); + }); + + it('should not navigate when tabLabels is replaced with equal labels but new object identities', async () => { + // Simulates a details page re-emitting tab labels after a store update (e.g. a list + // was added and the parent entity was replaced). The rebuilt labels must not cause a + // programmatic selectedTabChange -> router.navigate, which would cancel an in-flight + // navigation such as the redirect to the new List Details page. + host.tabLabels = [{ label: 'Lists' }, { label: 'Used By' }]; + fixture.detectChanges(); + await flushMicrotasks(); + + expect(mockRouter.navigate).not.toHaveBeenCalled(); + }); + + it('should navigate with the tab query param when the user changes tabs', async () => { + const tabHeaders: NodeListOf = fixture.nativeElement.querySelectorAll('.mat-mdc-tab'); + expect(tabHeaders.length).toBe(2); + + tabHeaders[1].click(); + fixture.detectChanges(); + await flushMicrotasks(); + + expect(mockRouter.navigate).toHaveBeenCalledWith([], { + relativeTo: mockRoute, + queryParams: { tab: 1 }, + queryParamsHandling: 'merge', + replaceUrl: true, + }); + expect(host.onSelectedTabChange).toHaveBeenCalledWith(1); + }); +}); diff --git a/packages/types/src/Experiment/interfaces.ts b/packages/types/src/Experiment/interfaces.ts index 288d7571a0..e48e93020f 100644 --- a/packages/types/src/Experiment/interfaces.ts +++ b/packages/types/src/Experiment/interfaces.ts @@ -308,6 +308,7 @@ export interface IMenuButtonItem { action: string; label: string; // transalation key disabled: boolean; + preserveCase?: boolean; } export interface IImportFile {