From 378a0362c46ec3198396df80ce4acef76c34e894 Mon Sep 17 00:00:00 2001 From: Zack Lee Date: Wed, 9 Sep 2026 22:11:18 -0400 Subject: [PATCH 01/61] feat: guard batch segment deletion against concurrent usage changes --- .../src/api/services/SegmentService.ts | 13 +- .../services/batch/SegmentDeletionGuard.ts | 99 +++++ .../integration/SegmentDeletionGuard/index.ts | 420 ++++++++++++++++++ .../backend/test/integration/index.test.ts | 2 + 4 files changed, 530 insertions(+), 4 deletions(-) create mode 100644 packages/backend/src/api/services/batch/SegmentDeletionGuard.ts create mode 100644 packages/backend/test/integration/SegmentDeletionGuard/index.ts diff --git a/packages/backend/src/api/services/SegmentService.ts b/packages/backend/src/api/services/SegmentService.ts index a235f97a0..316dfbf2a 100644 --- a/packages/backend/src/api/services/SegmentService.ts +++ b/packages/backend/src/api/services/SegmentService.ts @@ -535,7 +535,11 @@ export class SegmentService { return this.addSegmentDataWithPipeline(segment, logger, transactionalEntityManager, skipScheduleRecompute); } - public async deleteSegment(id: string, logger: UpgradeLogger): Promise { + public async deleteSegment( + id: string, + logger: UpgradeLogger, + beforeDelete?: (manager: EntityManager) => Promise + ): Promise { logger.info({ message: `Delete segment by id. segmentId: ${id}` }); // Both flags and experiments can reference this segment, so both precomputed tables must be @@ -549,9 +553,10 @@ export class SegmentService { logger, () => this.featureFlagPrecomputedSegmentService.getAffectedFlagIds(id), () => - this.dataSource.transaction((transactionalEntityManager) => - this.deleteSegmentAndPrivateSubsegments(id, logger, transactionalEntityManager) - ) + this.dataSource.transaction(async (transactionalEntityManager) => { + await beforeDelete?.(transactionalEntityManager); + return this.deleteSegmentAndPrivateSubsegments(id, logger, transactionalEntityManager); + }) ); this.experimentPrecomputedSegmentService.scheduleRecomputeForExperiments(affectedExperimentIds, logger); diff --git a/packages/backend/src/api/services/batch/SegmentDeletionGuard.ts b/packages/backend/src/api/services/batch/SegmentDeletionGuard.ts new file mode 100644 index 000000000..62caabb3f --- /dev/null +++ b/packages/backend/src/api/services/batch/SegmentDeletionGuard.ts @@ -0,0 +1,99 @@ +import { EntityManager } from 'typeorm'; +import { EXPERIMENT_STATE, SEGMENT_TYPE, STANDARD_LIST_TYPE } from 'upgrade_types'; + +export class SegmentDeletionBlockedError extends Error { + constructor(public readonly segmentId: string, public readonly reason: 'missing' | 'protected' | 'used') { + super(`Segment ${segmentId} cannot be deleted: ${reason}`); + this.name = 'SegmentDeletionBlockedError'; + } +} + +const ancestorQuery = ` + WITH RECURSIVE ancestors(id) AS ( + SELECT $1::uuid + UNION + SELECT link."parentSegmentId" + FROM segment_for_segment link + JOIN ancestors ON link."childSegmentId" = ancestors.id + ) + SELECT id FROM ancestors ORDER BY id +`; + +/** Batch-only guard. Call inside the transaction that will delete this segment. */ +export async function assertSegmentDeletionAllowed(id: string, manager: EntityManager): Promise { + if (!manager.queryRunner?.isTransactionActive) { + throw new Error('Segment deletion guard requires an active transaction'); + } + const [{ transaction_isolation: isolation }] = await manager.query('SHOW transaction_isolation'); + if (isolation !== 'read committed') { + throw new Error('Segment deletion guard requires READ COMMITTED isolation'); + } + + // Cap each lock wait through cleanup/deletion; preserve any tighter caller/database limit. + // Transaction-local settings expire on commit/rollback. This is not a total request deadline. + await manager.query(`SELECT set_config('lock_timeout', CASE + WHEN current_setting('lock_timeout')::interval = interval '0' + OR current_setting('lock_timeout')::interval > interval '5 seconds' + THEN '5s' ELSE current_setting('lock_timeout') END, true)`); + + // FOR UPDATE conflicts with the KEY SHARE locks taken by new foreign-key references. + const [target] = await manager.query('SELECT id, type FROM segment WHERE id = $1 FOR UPDATE', [id]); + if (!target) { + throw new SegmentDeletionBlockedError(id, 'missing'); + } + if (target.type !== SEGMENT_TYPE.PUBLIC) { + throw new SegmentDeletionBlockedError(id, 'protected'); + } + + const locked = new Set([id]); + let ancestors: string[]; + let unlocked: string[]; + do { + const rows: { id: string }[] = await manager.query(ancestorQuery, [id]); + ancestors = rows.map((row) => row.id); + unlocked = ancestors.filter((ancestor) => !locked.has(ancestor)); + if (unlocked.length) { + const acquired: { id: string }[] = await manager.query( + 'SELECT id FROM segment WHERE id = ANY($1::uuid[]) ORDER BY id FOR UPDATE', + [unlocked] + ); + acquired.forEach((row) => locked.add(row.id)); + } + // A writer may have committed while a lock was being acquired. Discover again with a fresh snapshot. + } while (unlocked.length); + + // Archived owners must also be locked: reactivation can make an otherwise unused segment Used. + // KEY SHARE would not stop a state-only UPDATE; SHARE does. + await manager.query( + `SELECT experiment.id FROM experiment WHERE experiment.id IN ( + SELECT "experimentId" FROM experiment_segment_inclusion WHERE "segmentId" = ANY($1::uuid[]) + UNION + SELECT "experimentId" FROM experiment_segment_exclusion WHERE "segmentId" = ANY($1::uuid[]) + ) ORDER BY experiment.id FOR SHARE`, + [ancestors] + ); + + const [{ used }] = await manager.query( + `SELECT + EXISTS ( + SELECT 1 FROM experiment_segment_inclusion ref JOIN experiment ON experiment.id = ref."experimentId" + WHERE ref."segmentId" = ANY($2::uuid[]) AND experiment.state <> $3 + ) OR EXISTS ( + SELECT 1 FROM experiment_segment_exclusion ref JOIN experiment ON experiment.id = ref."experimentId" + WHERE ref."segmentId" = ANY($2::uuid[]) AND experiment.state <> $3 + ) OR EXISTS ( + SELECT 1 FROM feature_flag_segment_inclusion WHERE "segmentId" = ANY($2::uuid[]) + ) OR EXISTS ( + SELECT 1 FROM feature_flag_segment_exclusion WHERE "segmentId" = ANY($2::uuid[]) + ) OR EXISTS ( + SELECT 1 FROM segment_for_segment child + JOIN segment list ON list.id = child."parentSegmentId" + WHERE child."childSegmentId" = $1 AND list."listType" = $4 + AND EXISTS (SELECT 1 FROM segment_for_segment parent WHERE parent."childSegmentId" = list.id) + ) AS used`, + [id, ancestors, EXPERIMENT_STATE.ARCHIVED, STANDARD_LIST_TYPE.SEGMENT] + ); + if (used) { + throw new SegmentDeletionBlockedError(id, 'used'); + } +} diff --git a/packages/backend/test/integration/SegmentDeletionGuard/index.ts b/packages/backend/test/integration/SegmentDeletionGuard/index.ts new file mode 100644 index 000000000..44ebd69aa --- /dev/null +++ b/packages/backend/test/integration/SegmentDeletionGuard/index.ts @@ -0,0 +1,420 @@ +import { randomUUID } from 'crypto'; +import Container from 'typedi'; +import { DataSource, EntityManager, QueryRunner } from 'typeorm'; +import { + ASSIGNMENT_UNIT, + EXPERIMENT_STATE, + POST_EXPERIMENT_RULE, + SEGMENT_STATUS, + SEGMENT_TYPE, + STANDARD_LIST_TYPE, +} from 'upgrade_types'; +import { Experiment } from '../../../src/api/models/Experiment'; +import { ExperimentSegmentInclusion } from '../../../src/api/models/ExperimentSegmentInclusion'; +import { ExperimentSegmentExclusion } from '../../../src/api/models/ExperimentSegmentExclusion'; +import { FeatureFlag } from '../../../src/api/models/FeatureFlag'; +import { FeatureFlagSegmentInclusion } from '../../../src/api/models/FeatureFlagSegmentInclusion'; +import { FeatureFlagSegmentExclusion } from '../../../src/api/models/FeatureFlagSegmentExclusion'; +import { IndividualForSegment } from '../../../src/api/models/IndividualForSegment'; +import { Segment } from '../../../src/api/models/Segment'; +import { SegmentService, SegmentWithStatus } from '../../../src/api/services/SegmentService'; +import { assertSegmentDeletionAllowed } from '../../../src/api/services/batch/SegmentDeletionGuard'; +import { UpgradeLogger } from '../../../src/lib/logger/UpgradeLogger'; + +const changes = [ + 'experiment inclusion', + 'experiment exclusion', + 'flag inclusion', + 'flag exclusion', + 'nested experiment inclusion', + 'nested experiment exclusion', + 'nested flag inclusion', + 'nested flag exclusion', + 'nested parent', + 'archived owner state', + 'parent list type', +] as const; +type Change = (typeof changes)[number]; + +const barrier = () => { + let release: () => void; + const reached = new Promise((resolve) => { + release = resolve; + }); + return { reached, release }; +}; +const outcome = (work: Promise): Promise<{ value?: T; error?: any }> => + work.then( + (value) => ({ value }), + (error) => ({ error }) + ); + +const waitForBarrier = (signal: ReturnType, work: ReturnType) => + Promise.race([ + signal.reached, + work.then((result) => { + throw result.error || new Error('Deletion completed before the test barrier'); + }), + ]); + +const createSegment = async (db: DataSource, type = SEGMENT_TYPE.PUBLIC, children: Segment[] = []) => { + const repo = db.getRepository(Segment); + return repo.save( + repo.create({ + id: randomUUID(), + name: 'Guard fixture', + context: 'guard-test', + type, + listType: children.length ? STANDARD_LIST_TYPE.SEGMENT : STANDARD_LIST_TYPE.INDIVIDUAL, + subSegments: children, + }) + ); +}; + +const createChange = async (db: DataSource, change: Change) => { + const target = await createSegment(db); + await db.getRepository(IndividualForSegment).insert({ segmentId: target.id, userId: 'retained-on-rejection' }); + const nested = change.startsWith('nested') || change === 'archived owner state' || change === 'parent list type'; + const referenced = nested ? await createSegment(db, SEGMENT_TYPE.PRIVATE, [target]) : target; + let apply: (manager: EntityManager) => Promise; + + if (change.includes('experiment') || change === 'archived owner state') { + const repo = db.getRepository(Experiment); + const experiment = await repo.save( + repo.create({ + id: randomUUID(), + name: 'Guard owner', + description: '', + context: ['guard-test'], + state: change === 'archived owner state' ? EXPERIMENT_STATE.ARCHIVED : EXPERIMENT_STATE.INACTIVE, + assignmentUnit: ASSIGNMENT_UNIT.INDIVIDUAL, + postExperimentRule: POST_EXPERIMENT_RULE.CONTINUE, + }) + ); + const entity = change.includes('exclusion') ? ExperimentSegmentExclusion : ExperimentSegmentInclusion; + const ref = { experimentId: experiment.id, segmentId: referenced.id }; + apply = (manager) => manager.getRepository(entity).insert(ref); + if (change === 'archived owner state') { + await apply(db.manager); + apply = (manager) => + manager.getRepository(Experiment).update(experiment.id, { state: EXPERIMENT_STATE.INACTIVE }); + } + } else if (change.includes('flag')) { + const repo = db.getRepository(FeatureFlag); + const flag = await repo.save( + repo.create({ + id: randomUUID(), + name: 'Guard owner', + key: randomUUID(), + description: '', + context: ['guard-test'], + }) + ); + const entity = change.includes('exclusion') ? FeatureFlagSegmentExclusion : FeatureFlagSegmentInclusion; + apply = (manager) => + manager.getRepository(entity).insert({ + featureFlagId: flag.id, + segmentId: referenced.id, + enabled: false, + listType: STANDARD_LIST_TYPE.SEGMENT, + }); + } else { + const parent = await createSegment(db); + apply = (manager) => manager.createQueryBuilder().relation(Segment, 'subSegments').of(parent.id).add(referenced.id); + if (change === 'parent list type') { + await db.getRepository(Segment).update(referenced.id, { listType: STANDARD_LIST_TYPE.INDIVIDUAL }); + await apply(db.manager); + apply = (manager) => + manager.getRepository(Segment).update(referenced.id, { listType: STANDARD_LIST_TYPE.SEGMENT }); + } + } + return { target, referenced, apply, writerCanFinishAfterDeletion: nested }; +}; + +const backendPid = async (manager: EntityManager) => + Number((await manager.query('SELECT pg_backend_pid() AS pid'))[0].pid); + +// Observe an actual PostgreSQL lock dependency; no sleep duration determines transaction ordering. +const waitForBlock = async (db: DataSource, blocked: number, blocker: number) => { + const deadline = Date.now() + 4000; + while (Date.now() < deadline) { + const [{ waiting }] = await db.query('SELECT $2::integer = ANY(pg_blocking_pids($1::integer)) AS waiting', [ + blocked, + blocker, + ]); + if (waiting) return; + await new Promise((resolve) => setImmediate(resolve)); + } + throw new Error(`Expected PostgreSQL backend ${blocked} to wait for ${blocker}`); +}; + +const startWriter = async (db: DataSource) => { + const writer = db.createQueryRunner(); + await writer.connect(); + await writer.startTransaction(); + await writer.query("SET LOCAL lock_timeout = '8s'"); + return writer; +}; +const closeWriter = async (writer: QueryRunner) => { + if (writer.isTransactionActive) await writer.rollbackTransaction(); + await writer.release(); +}; + +export function registerSegmentDeletionGuardTests(connections: () => [DataSource, DataSource]) { + describe('Segment deletion guard', () => { + const logger = new UpgradeLogger(); + let db: DataSource; + let writerDb: DataSource; + let service: SegmentService; + beforeEach(() => { + [db, writerDb] = connections(); + service = Container.get(SegmentService); + }); + + const status = async (id: string) => { + const segment = await db + .getRepository(Segment) + .findOneOrFail({ where: { id }, relations: { subSegments: true } }); + const result = (await service.getSegmentStatus([segment])).segmentsData[0] as SegmentWithStatus; + return result.status; + }; + + test.each(changes)('%s committed before the guard is rejected', async (change) => { + const fixture = await createChange(db, change); + expect(await status(fixture.target.id)).toBe(SEGMENT_STATUS.UNUSED); + await fixture.apply(writerDb.manager); + expect(await status(fixture.target.id)).toBe(SEGMENT_STATUS.USED); + await expect( + service.deleteSegment(fixture.target.id, logger, (manager) => + assertSegmentDeletionAllowed(fixture.target.id, manager) + ) + ).rejects.toMatchObject({ reason: 'used' }); + expect(await db.getRepository(IndividualForSegment).countBy({ segmentId: fixture.target.id })).toBe(1); + }); + + test.each(changes)( + '%s in progress is awaited and then rejected', + async (change) => { + const fixture = await createChange(db, change); + expect(await status(fixture.target.id)).toBe(SEGMENT_STATUS.UNUSED); + const writer = await startWriter(writerDb); + const entered = barrier(); + let deleterPid: number; + let deletion: ReturnType; + try { + const writerPid = await backendPid(writer.manager); + await fixture.apply(writer.manager); + deletion = outcome( + service.deleteSegment(fixture.target.id, logger, async (manager) => { + deleterPid = await backendPid(manager); + entered.release(); + await assertSegmentDeletionAllowed(fixture.target.id, manager); + }) + ); + await waitForBarrier(entered, deletion); + expect(deleterPid).not.toBe(writerPid); + await waitForBlock(db, deleterPid, writerPid); + await writer.commitTransaction(); + expect((await deletion).error).toMatchObject({ reason: 'used' }); + expect(await db.getRepository(Segment).countBy({ id: fixture.target.id })).toBe(1); + expect(await db.getRepository(IndividualForSegment).countBy({ segmentId: fixture.target.id })).toBe(1); + } finally { + await closeWriter(writer); + await deletion; + } + }, + 15000 + ); + + test.each(changes)( + '%s waits until an already guarded deletion commits', + async (change) => { + const fixture = await createChange(db, change); + expect(await status(fixture.target.id)).toBe(SEGMENT_STATUS.UNUSED); + const writer = await startWriter(writerDb); + const guarded = barrier(); + const finish = barrier(); + let deleterPid: number; + const deletion = outcome( + service.deleteSegment(fixture.target.id, logger, async (manager) => { + deleterPid = await backendPid(manager); + await assertSegmentDeletionAllowed(fixture.target.id, manager); + guarded.release(); + await finish.reached; + }) + ); + let writing: ReturnType; + try { + await waitForBarrier(guarded, deletion); + const writerPid = await backendPid(writer.manager); + expect(deleterPid).not.toBe(writerPid); + writing = outcome(fixture.apply(writer.manager)); + await waitForBlock(db, writerPid, deleterPid); + finish.release(); + expect((await deletion).error).toBeUndefined(); + const result = await writing; + if (fixture.writerCanFinishAfterDeletion) { + expect(result.error).toBeUndefined(); + await writer.commitTransaction(); + } else { + expect(result.error).toMatchObject({ code: '23503' }); + await writer.rollbackTransaction(); + } + expect(await db.getRepository(Segment).countBy({ id: fixture.target.id })).toBe(0); + } finally { + finish.release(); + await writing; + await closeWriter(writer); + await deletion; + } + }, + 15000 + ); + + test.each(['archived owner state', 'nested parent'] as const)( + 'a target-only lock does not protect %s (negative control)', + async (change) => { + const fixture = await createChange(db, change); + const writer = await startWriter(writerDb); + try { + await db.transaction(async (manager) => { + await manager.query('SELECT id FROM segment WHERE id = $1 FOR UPDATE', [fixture.target.id]); + expect(await status(fixture.target.id)).toBe(SEGMENT_STATUS.UNUSED); + // Completes despite the target lock: related ancestors and experiment owners also need protection. + await fixture.apply(writer.manager); + await writer.commitTransaction(); + expect(await status(fixture.target.id)).toBe(SEGMENT_STATUS.USED); + }); + } finally { + await closeWriter(writer); + } + } + ); + + test.each(['commit', 'rollback'] as const)( + 'bounds lock waits without leaking the setting after %s', + async (completion) => { + const target = await createSegment(db); + const runner = db.createQueryRunner(); + await runner.connect(); + const [original] = await runner.query('SHOW lock_timeout'); + try { + for (const [configured, expected] of [ + ['0', '5s'], + ['30s', '5s'], + ['200ms', '200ms'], + ]) { + await runner.query("SELECT set_config('lock_timeout', $1, false)", [configured]); + await runner.startTransaction(); + await assertSegmentDeletionAllowed(target.id, runner.manager); + expect(await runner.query('SHOW lock_timeout')).toEqual([{ lock_timeout: expected }]); + if (completion === 'commit') await runner.commitTransaction(); + else await runner.rollbackTransaction(); + // Check the same pinned connection, not an arbitrary pooled connection. + expect(await runner.query('SHOW lock_timeout')).toEqual([{ lock_timeout: configured }]); + } + } finally { + if (runner.isTransactionActive) await runner.rollbackTransaction(); + await runner.query("SELECT set_config('lock_timeout', $1, false)", [original.lock_timeout]); + await runner.release(); + } + } + ); + + test.each(['target', 'ancestor', 'experiment owner', 'private cleanup'] as const)( + 'a lock timeout during %s rejects deletion and rolls back prior writes', + async (blockedAt) => { + const fixture = await createChange(db, 'archived owner state'); + const child = blockedAt === 'private cleanup' ? await createSegment(db, SEGMENT_TYPE.PRIVATE) : undefined; + if (child) { + await db.createQueryBuilder().relation(Segment, 'subSegments').of(fixture.target.id).add(child.id); + } + const writer = await startWriter(writerDb); + let guardPassed = false; + try { + if (blockedAt === 'experiment owner') { + await writer.query( + `SELECT id FROM experiment WHERE id IN ( + SELECT "experimentId" FROM experiment_segment_inclusion WHERE "segmentId" = $1 + ) FOR UPDATE`, + [fixture.referenced.id] + ); + } else { + const lockedId = child?.id ?? (blockedAt === 'ancestor' ? fixture.referenced.id : fixture.target.id); + await writer.query('SELECT id FROM segment WHERE id = $1 FOR UPDATE', [lockedId]); + } + const writerPid = await backendPid(writer.manager); + await expect( + service.deleteSegment(fixture.target.id, logger, async (manager) => { + expect(await backendPid(manager)).not.toBe(writerPid); + // A tighter transaction budget keeps this contention test fast and must be preserved. + await manager.query("SET LOCAL lock_timeout = '200ms'"); + await manager.getRepository(IndividualForSegment).delete({ segmentId: fixture.target.id }); + await assertSegmentDeletionAllowed(fixture.target.id, manager); + guardPassed = true; + }) + ).rejects.toMatchObject({ code: '55P03' }); + // The writer still holds its lock: failure comes from the timeout, not releasing the blocker. + expect(writer.isTransactionActive).toBe(true); + expect(guardPassed).toBe(blockedAt === 'private cleanup'); + expect(await db.getRepository(Segment).countBy({ id: fixture.target.id })).toBe(1); + expect(await db.getRepository(IndividualForSegment).countBy({ segmentId: fixture.target.id })).toBe(1); + if (child) expect(await db.getRepository(Segment).countBy({ id: child.id })).toBe(1); + } finally { + await closeWriter(writer); + } + }, + 15000 + ); + + test('rejects missing/protected targets and calls without a transaction', async () => { + await expect(assertSegmentDeletionAllowed(randomUUID(), db.manager)).rejects.toThrow('active transaction'); + await expect( + db.transaction('REPEATABLE READ', (manager) => assertSegmentDeletionAllowed(randomUUID(), manager)) + ).rejects.toThrow('READ COMMITTED'); + await expect( + db.transaction((manager) => assertSegmentDeletionAllowed(randomUUID(), manager)) + ).rejects.toMatchObject({ reason: 'missing' }); + for (const type of [SEGMENT_TYPE.PRIVATE, SEGMENT_TYPE.GLOBAL_EXCLUDE]) { + const segment = await createSegment(db, type); + await expect( + service.deleteSegment(segment.id, logger, (manager) => assertSegmentDeletionAllowed(segment.id, manager)) + ).rejects.toMatchObject({ reason: 'protected' }); + expect(await db.getRepository(Segment).countBy({ id: segment.id })).toBe(1); + } + }); + + test('guard rollback preserves prior writes and legacy deletion remains available', async () => { + const fixture = await createChange(db, 'flag inclusion'); + await fixture.apply(db.manager); + await expect( + service.deleteSegment(fixture.target.id, logger, async (manager) => { + await manager.getRepository(IndividualForSegment).delete({ segmentId: fixture.target.id }); + await assertSegmentDeletionAllowed(fixture.target.id, manager); + }) + ).rejects.toMatchObject({ reason: 'used' }); + expect(await db.getRepository(IndividualForSegment).countBy({ segmentId: fixture.target.id })).toBe(1); + await expect(service.deleteSegment(fixture.target.id, logger)).resolves.toMatchObject({ id: fixture.target.id }); + }); + + test('guarded deletion awaits private cleanup without global status loaders', async () => { + const child = await createSegment(db, SEGMENT_TYPE.PRIVATE); + const target = await createSegment(db, SEGMENT_TYPE.PUBLIC, [child]); + const spies = [ + 'getSegmentStatus', + 'getExperimentSegmentInclusionData', + 'getExperimentSegmentExclusionData', + 'getFeatureFlagSegmentInclusionData', + 'getFeatureFlagSegmentExclusionData', + 'getParentSegments', + ].map((method) => jest.spyOn(service as any, method)); + try { + await service.deleteSegment(target.id, logger, (manager) => assertSegmentDeletionAllowed(target.id, manager)); + expect(await db.getRepository(Segment).countBy({ id: child.id })).toBe(0); + spies.forEach((spy) => expect(spy).not.toHaveBeenCalled()); + } finally { + spies.forEach((spy) => spy.mockRestore()); + } + }); + }); +} diff --git a/packages/backend/test/integration/index.test.ts b/packages/backend/test/integration/index.test.ts index 2ce690e0b..e0cf9518e 100644 --- a/packages/backend/test/integration/index.test.ts +++ b/packages/backend/test/integration/index.test.ts @@ -124,6 +124,7 @@ import { ExperimentValidation } from './Experiment/validation'; import { FeatureFlagInclusionExclusion } from './FeatureFlags'; import { ListValueFiltering } from './ListValueFiltering'; import { FeatureFlagDeleteCleanup, SegmentDeleteCleanup } from './DeleteCleanup'; +import { registerSegmentDeletionGuardTests } from './SegmentDeletionGuard'; describe('Integration Tests', () => { jest.setTimeout(100000000); @@ -133,6 +134,7 @@ describe('Integration Tests', () => { let defaultConnection: DataSource; let exportConnection: DataSource; + registerSegmentDeletionGuardTests(() => [defaultConnection, exportConnection]); beforeAll(async () => { configureLogger(); [defaultConnection, exportConnection] = await createDatabaseConnection(); From f2ae6b4a60ceaa00d53480f8c7dc681ef3ba8a3b Mon Sep 17 00:00:00 2001 From: Zack Lee Date: Thu, 10 Sep 2026 00:59:45 -0400 Subject: [PATCH 02/61] feat: add batch deletion eligibility endpoints --- .../api/controllers/ExperimentController.ts | 38 +- .../api/controllers/FeatureFlagController.ts | 41 ++- .../src/api/controllers/SegmentController.ts | 82 ++++- .../validators/BatchEntityIdsValidator.ts | 10 + .../batch/DeletionEligibilityService.ts | 140 +++++++ .../integration/DeletionEligibility/index.ts | 342 ++++++++++++++++++ .../backend/test/integration/index.test.ts | 2 + .../controllers/ExperimentController.test.ts | 2 + .../controllers/FeatureFlagController.test.ts | 2 + .../controllers/SegmentController.test.ts | 2 + .../batch/DeletionEligibilityService.test.ts | 98 +++++ packages/types/src/BatchActions/index.ts | 57 +++ packages/types/src/index.ts | 10 + 13 files changed, 823 insertions(+), 3 deletions(-) create mode 100644 packages/backend/src/api/controllers/validators/BatchEntityIdsValidator.ts create mode 100644 packages/backend/src/api/services/batch/DeletionEligibilityService.ts create mode 100644 packages/backend/test/integration/DeletionEligibility/index.ts create mode 100644 packages/backend/test/unit/services/batch/DeletionEligibilityService.test.ts create mode 100644 packages/types/src/BatchActions/index.ts diff --git a/packages/backend/src/api/controllers/ExperimentController.ts b/packages/backend/src/api/controllers/ExperimentController.ts index a337c0edf..78e77b78f 100644 --- a/packages/backend/src/api/controllers/ExperimentController.ts +++ b/packages/backend/src/api/controllers/ExperimentController.ts @@ -1,3 +1,6 @@ +import { DeletionEligibilityResult } from 'upgrade_types'; +import { BatchEntityIdsValidator } from './validators/BatchEntityIdsValidator'; +import { DeletionEligibilityService } from '../services/batch/DeletionEligibilityService'; import { Body, Get, @@ -664,9 +667,42 @@ export class ExperimentController { public moocletExperimentService: MoocletExperimentService, public moocletRewardService: MoocletRewardsService, public importExportService: ImportExportService, - public cacheService: CacheService + public cacheService: CacheService, + private deletionEligibilityService: DeletionEligibilityService ) {} + /** + * @swagger + * /experiments/deletion-eligibility: + * post: + * summary: Check deletion eligibility for the selected experiments + * description: Read-only; returns one result per ID, including hidden selections. Does not reserve or delete items. + * tags: + * - Experiments + * parameters: + * - in: body + * name: selection + * required: true + * schema: + * $ref: '#/definitions/BatchEntityIdsRequest' + * responses: + * '200': + * description: Eligibility and reasons, in request order; users without delete permission receive canDelete=false. + * schema: + * $ref: '#/definitions/DeletionEligibilityResult' + * '400': + * description: Expected a nonempty array of unique UUIDs. + * '401': + * description: A current authenticated user is required. + */ + @Post('/deletion-eligibility') + public getDeletionEligibility( + @Body({ validate: true }) { ids }: BatchEntityIdsValidator, + @CurrentUser({ required: true }) currentUser: UserDTO + ): Promise { + return this.deletionEligibilityService.experiments(ids, currentUser); + } + /** * @swagger * /experiments/names: diff --git a/packages/backend/src/api/controllers/FeatureFlagController.ts b/packages/backend/src/api/controllers/FeatureFlagController.ts index bbac5ebbe..307b9f68a 100644 --- a/packages/backend/src/api/controllers/FeatureFlagController.ts +++ b/packages/backend/src/api/controllers/FeatureFlagController.ts @@ -1,3 +1,6 @@ +import { DeletionEligibilityResult } from 'upgrade_types'; +import { BatchEntityIdsValidator } from './validators/BatchEntityIdsValidator'; +import { DeletionEligibilityService } from '../services/batch/DeletionEligibilityService'; import { JsonController, Authorized, @@ -154,7 +157,43 @@ interface FeatureFlagsPaginationInfo extends PaginationResponse { @Authorized() @JsonController('/flags') export class FeatureFlagsController { - constructor(public featureFlagService: FeatureFlagService, public experimentUserService: ExperimentUserService) {} + constructor( + public featureFlagService: FeatureFlagService, + public experimentUserService: ExperimentUserService, + private deletionEligibilityService: DeletionEligibilityService + ) {} + + /** + * @swagger + * /flags/deletion-eligibility: + * post: + * summary: Check deletion eligibility for the selected flags + * description: Read-only; returns one result per ID, including hidden selections. Does not reserve or delete items. + * tags: + * - Feature Flags + * parameters: + * - in: body + * name: selection + * required: true + * schema: + * $ref: '#/definitions/BatchEntityIdsRequest' + * responses: + * '200': + * description: Eligibility and reasons, in request order; users without delete permission receive canDelete=false. + * schema: + * $ref: '#/definitions/DeletionEligibilityResult' + * '400': + * description: Expected a nonempty array of unique UUIDs. + * '401': + * description: A current authenticated user is required. + */ + @Post('/deletion-eligibility') + public getDeletionEligibility( + @Body({ validate: true }) { ids }: BatchEntityIdsValidator, + @CurrentUser({ required: true }) currentUser: UserDTO + ): Promise { + return this.deletionEligibilityService.flags(ids, currentUser); + } /** * @swagger diff --git a/packages/backend/src/api/controllers/SegmentController.ts b/packages/backend/src/api/controllers/SegmentController.ts index 4255507ff..444d7db51 100644 --- a/packages/backend/src/api/controllers/SegmentController.ts +++ b/packages/backend/src/api/controllers/SegmentController.ts @@ -1,5 +1,10 @@ +import { UserDTO } from '../DTO/UserDTO'; +import { DeletionEligibilityResult } from 'upgrade_types'; +import { BatchEntityIdsValidator } from './validators/BatchEntityIdsValidator'; +import { DeletionEligibilityService } from '../services/batch/DeletionEligibilityService'; import { JsonController, + CurrentUser, Get, Delete, Authorized, @@ -49,6 +54,49 @@ interface SegmentPaginationInfo extends PaginationResponse { /** * @swagger * definitions: + * BatchEntityIdsRequest: + * type: object + * required: [ids] + * properties: + * ids: + * type: array + * minItems: 1 + * uniqueItems: true + * items: + * type: string + * format: uuid + * DeletionEligibilityItem: + * type: object + * required: [id, availability, canDelete] + * properties: + * id: + * type: string + * format: uuid + * availability: + * type: string + * enum: [present, not_found, unavailable] + * name: + * type: string + * stateOrStatus: + * type: string + * segmentType: + * type: string + * enum: [public, private, global_exclude] + * canDelete: + * type: boolean + * reasonCode: + * type: string + * enum: [not_found, missing_permission, experiment_state_unsupported, feature_flag_enabled, feature_flag_status_unsupported, segment_in_use, protected_segment_type, eligibility_unavailable] + * DeletionEligibilityResult: + * type: object + * required: [items, allDeletable] + * properties: + * items: + * type: array + * items: + * $ref: '#/definitions/DeletionEligibilityItem' + * allDeletable: + * type: boolean * Segment: * required: * - name @@ -233,7 +281,39 @@ interface SegmentPaginationInfo extends PaginationResponse { @Authorized() @JsonController('/segments') export class SegmentController { - constructor(public segmentService: SegmentService) {} + constructor(public segmentService: SegmentService, private deletionEligibilityService: DeletionEligibilityService) {} + + /** + * @swagger + * /segments/deletion-eligibility: + * post: + * summary: Check deletion eligibility for the selected segments + * description: Read-only; returns one result per ID, including hidden selections. Does not reserve or delete items. + * tags: + * - Segment + * parameters: + * - in: body + * name: selection + * required: true + * schema: + * $ref: '#/definitions/BatchEntityIdsRequest' + * responses: + * '200': + * description: Eligibility and reasons, in request order; users without delete permission receive canDelete=false. + * schema: + * $ref: '#/definitions/DeletionEligibilityResult' + * '400': + * description: Expected a nonempty array of unique UUIDs. + * '401': + * description: A current authenticated user is required. + */ + @Post('/deletion-eligibility') + public getDeletionEligibility( + @Body({ validate: true }) { ids }: BatchEntityIdsValidator, + @CurrentUser({ required: true }) currentUser: UserDTO + ): Promise { + return this.deletionEligibilityService.segments(ids, currentUser); + } /** * @swagger diff --git a/packages/backend/src/api/controllers/validators/BatchEntityIdsValidator.ts b/packages/backend/src/api/controllers/validators/BatchEntityIdsValidator.ts new file mode 100644 index 000000000..8eb755b9c --- /dev/null +++ b/packages/backend/src/api/controllers/validators/BatchEntityIdsValidator.ts @@ -0,0 +1,10 @@ +import { ArrayNotEmpty, ArrayUnique, IsArray, IsUUID } from 'class-validator'; +import { BatchEntityIdsRequest } from 'upgrade_types'; + +export class BatchEntityIdsValidator implements BatchEntityIdsRequest { + @IsArray() + @ArrayNotEmpty() + @ArrayUnique((id: unknown) => (typeof id === 'string' ? id.toLowerCase() : id)) + @IsUUID('all', { each: true }) + public ids: string[]; +} diff --git a/packages/backend/src/api/services/batch/DeletionEligibilityService.ts b/packages/backend/src/api/services/batch/DeletionEligibilityService.ts new file mode 100644 index 000000000..a30c16b8c --- /dev/null +++ b/packages/backend/src/api/services/batch/DeletionEligibilityService.ts @@ -0,0 +1,140 @@ +import { Service } from 'typedi'; +import { DataSource, In } from 'typeorm'; +import { UnauthorizedError } from 'routing-controllers'; +import { + DeletionEligibilityItem, + DeletionEligibilityResult, + DeletionReasonCode, + EXPERIMENT_STATE, + EXPERIMENT_STATE_DISPLAY_NAME_OVERRIDES, + FEATURE_FLAG_STATUS, + SEGMENT_STATUS, + SEGMENT_TYPE, + UserRole, +} from 'upgrade_types'; +import { InjectDataSource } from '../../../typeorm-typedi-extensions'; +import { UserDTO } from '../../DTO/UserDTO'; +import { Experiment } from '../../models/Experiment'; +import { FeatureFlag } from '../../models/FeatureFlag'; +import { Segment } from '../../models/Segment'; +import { SegmentService, SegmentWithStatus } from '../SegmentService'; + +type EligibilitySummary = Omit; + +const deletableExperimentStates = new Set([ + EXPERIMENT_STATE.INACTIVE, + EXPERIMENT_STATE.RUNNING, + EXPERIMENT_STATE.PAUSED, + EXPERIMENT_STATE.COMPLETED, + EXPERIMENT_STATE.ARCHIVED, +]); + +/** Read-only batch checks, also used by the initial batch-delete preflight. */ +@Service() +export class DeletionEligibilityService { + constructor(@InjectDataSource() private dataSource: DataSource, private segmentService: SegmentService) {} + + public async experiments(ids: string[], user: UserDTO): Promise { + const permission = this.permissionReason(user); + const rows = await this.dataSource.getRepository(Experiment).find({ + where: { id: In(ids) }, + select: { id: true, name: true, state: true }, + }); + return this.result(ids, rows, (row) => { + const state = EXPERIMENT_STATE_DISPLAY_NAME_OVERRIDES[row.state] || row.state; + return { + availability: 'present', + name: row.name, + stateOrStatus: state, + reasonCode: + permission || + (deletableExperimentStates.has(state) ? undefined : DeletionReasonCode.EXPERIMENT_STATE_UNSUPPORTED), + }; + }); + } + + public async flags(ids: string[], user: UserDTO): Promise { + const permission = this.permissionReason(user); + const rows = await this.dataSource.getRepository(FeatureFlag).find({ + where: { id: In(ids) }, + select: { id: true, name: true, status: true }, + }); + return this.result(ids, rows, (row) => ({ + availability: 'present', + name: row.name, + stateOrStatus: row.status, + reasonCode: + permission || + (row.status === FEATURE_FLAG_STATUS.ENABLED + ? DeletionReasonCode.FEATURE_FLAG_ENABLED + : [FEATURE_FLAG_STATUS.DISABLED, FEATURE_FLAG_STATUS.ARCHIVED].includes(row.status) + ? undefined + : DeletionReasonCode.FEATURE_FLAG_STATUS_UNSUPPORTED), + })); + } + + public async segments(ids: string[], user: UserDTO): Promise { + const permission = this.permissionReason(user, true); + const rows = await this.dataSource + .getRepository(Segment) + .createQueryBuilder('segment') + .leftJoin('segment.subSegments', 'child') + .select(['segment.id', 'segment.name', 'segment.type', 'child.id']) + .where('segment.id IN (:...ids)', { ids }) + .getMany(); + const publicRows = rows.filter((row) => row.type === SEGMENT_TYPE.PUBLIC); + // One evaluation for the entire selection; no per-ID detail/member loads or mutation locks. + const statuses = publicRows.length + ? ((await this.segmentService.getSegmentStatus(publicRows)).segmentsData as SegmentWithStatus[]) + : []; + const statusById = new Map(statuses.map((row) => [row.id, row.status])); + return this.result(ids, rows, (row) => { + // Private list internals are not part of the ordinary public-segment selection response. + if (row.type === SEGMENT_TYPE.PRIVATE) { + return { availability: 'unavailable', reasonCode: DeletionReasonCode.PROTECTED_SEGMENT_TYPE }; + } + const status = row.type === SEGMENT_TYPE.GLOBAL_EXCLUDE ? SEGMENT_STATUS.EXCLUDED : statusById.get(row.id); + return { + availability: status ? 'present' : 'unavailable', + name: row.name, + segmentType: row.type, + stateOrStatus: status, + reasonCode: + permission || + (row.type !== SEGMENT_TYPE.PUBLIC + ? DeletionReasonCode.PROTECTED_SEGMENT_TYPE + : status === SEGMENT_STATUS.UNUSED + ? undefined + : status === SEGMENT_STATUS.USED + ? DeletionReasonCode.SEGMENT_IN_USE + : DeletionReasonCode.ELIGIBILITY_UNAVAILABLE), + }; + }); + } + + private permissionReason(user: UserDTO, segments = false): DeletionReasonCode | undefined { + if (!user) throw new UnauthorizedError('A current user is required'); + const allowed = + user.role === UserRole.ADMIN || + user.role === UserRole.CREATOR || + (segments && user.role === UserRole.USER_MANAGER); + return allowed ? undefined : DeletionReasonCode.MISSING_PERMISSION; + } + + private result( + ids: string[], + rows: T[], + summarize: (row: T) => EligibilitySummary + ): DeletionEligibilityResult { + const byId = new Map(rows.map((row) => [row.id.toLowerCase(), row])); + const items = ids.map((id): DeletionEligibilityItem => { + const row = byId.get(id.toLowerCase()); + if (!row) { + return { id, availability: 'not_found', canDelete: false, reasonCode: DeletionReasonCode.NOT_FOUND }; + } + const summary = summarize(row); + return { id, ...summary, canDelete: summary.availability === 'present' && !summary.reasonCode }; + }); + return { items, allDeletable: items.length > 0 && items.every((item) => item.canDelete) }; + } +} diff --git a/packages/backend/test/integration/DeletionEligibility/index.ts b/packages/backend/test/integration/DeletionEligibility/index.ts new file mode 100644 index 000000000..dfe34d07a --- /dev/null +++ b/packages/backend/test/integration/DeletionEligibility/index.ts @@ -0,0 +1,342 @@ +import { randomUUID } from 'crypto'; +import { Application } from 'express'; +import request from 'supertest'; +import Container from 'typedi'; +import { createExpressServer } from 'routing-controllers'; +import { DataSource } from 'typeorm'; +import { + ASSIGNMENT_UNIT, + DeletionReasonCode, + EXPERIMENT_STATE, + FEATURE_FLAG_STATUS, + POST_EXPERIMENT_RULE, + SEGMENT_TYPE, + STANDARD_LIST_TYPE, + SYSTEM_USER_EMAIL, + UserRole, +} from 'upgrade_types'; +import { ExperimentController } from '../../../src/api/controllers/ExperimentController'; +import { FeatureFlagsController } from '../../../src/api/controllers/FeatureFlagController'; +import { SegmentController } from '../../../src/api/controllers/SegmentController'; +import { ErrorHandlerMiddleware } from '../../../src/api/middlewares/ErrorHandlerMiddleware'; +import { LogMiddleware } from '../../../src/api/middlewares/LogMiddleware'; +import { Experiment } from '../../../src/api/models/Experiment'; +import { ExperimentSegmentInclusion } from '../../../src/api/models/ExperimentSegmentInclusion'; +import { FeatureFlag } from '../../../src/api/models/FeatureFlag'; +import { FeatureFlagSegmentExclusion } from '../../../src/api/models/FeatureFlagSegmentExclusion'; +import { IndividualForSegment } from '../../../src/api/models/IndividualForSegment'; +import { Segment } from '../../../src/api/models/Segment'; +import { User } from '../../../src/api/models/User'; +import { DeletionEligibilityService } from '../../../src/api/services/batch/DeletionEligibilityService'; +import { SegmentService } from '../../../src/api/services/SegmentService'; +import { currentUserChecker } from '../../../src/auth/currentUserChecker'; +import { env } from '../../../src/env'; +import { iocLoader } from '../../../src/loaders/iocLoader'; + +const entities = ['experiments', 'flags', 'segments'] as const; +type Entity = (typeof entities)[number]; +const route = (entity: Entity) => `/api/${entity}/deletion-eligibility`; + +export function registerDeletionEligibilityTests(connections: () => [DataSource, DataSource]) { + describe('Deletion eligibility', () => { + let db: DataSource; + let app: Application; + let originalAuth: boolean; + beforeAll(() => { + iocLoader(); + // The global unit-test setup mocks auth. These HTTP tests use the real checker and seeded users. + const { authorizationChecker } = jest.requireActual('../../../src/auth/authorizationChecker'); + app = createExpressServer({ + routePrefix: '/api', + controllers: [ExperimentController, FeatureFlagsController, SegmentController], + middlewares: [LogMiddleware, ErrorHandlerMiddleware], + classTransformer: true, + validation: { whitelist: true, validationError: { target: false, value: false } }, + defaultErrorHandler: false, + authorizationChecker: authorizationChecker(), + currentUserChecker, + }); + }); + beforeEach(() => { + [db] = connections(); + originalAuth = env.google.authTokenRequired; + env.google.authTokenRequired = false; + }); + afterEach(() => { + env.google.authTokenRequired = originalAuth; + jest.restoreAllMocks(); + }); + + const create = async (entity: Entity, count = 1) => { + const rows = Array.from({ length: count }, (_, index) => ({ + id: randomUUID(), + name: `Eligibility ${index}`, + context: 'home', + })); + if (entity === 'experiments') { + await db.getRepository(Experiment).insert( + rows.map((row) => ({ + ...row, + context: [row.context], + description: '', + assignmentUnit: ASSIGNMENT_UNIT.INDIVIDUAL, + postExperimentRule: POST_EXPERIMENT_RULE.CONTINUE, + })) + ); + } else if (entity === 'flags') { + await db.getRepository(FeatureFlag).insert( + rows.map((row) => ({ + ...row, + context: [row.context], + key: row.id, + description: '', + })) + ); + } else { + await db.getRepository(Segment).insert(rows.map((row) => ({ ...row, type: SEGMENT_TYPE.PUBLIC }))); + } + return rows; + }; + const list = async (name: string, child: { id: string }) => { + const row = { + id: randomUUID(), + name, + context: 'home', + type: SEGMENT_TYPE.PRIVATE, + listType: STANDARD_LIST_TYPE.SEGMENT, + }; + await db.getRepository(Segment).insert(row); + // Match the existing Add List service's relation write; avoid cascading saves of partial children. + await db.createQueryBuilder().relation(Segment, 'subSegments').of(row.id).add(child.id); + return row; + }; + + test.each(entities)('%s rejects invalid IDs before the eligibility service runs', async (entity) => { + const service = Container.get(DeletionEligibilityService); + const spy = jest.spyOn(service, entity); + const id = randomUUID(); + const invalid = [ + {}, + { ids: [] }, + { ids: null }, + { ids: id }, + { ids: [42] }, + { ids: ['bad-id'] }, + { ids: [id, 'bad-id'] }, + { ids: [id, id] }, + { ids: [id, id.toUpperCase()] }, + ]; + for (const body of invalid) await request(app).post(route(entity)).send(body).expect(400); + expect(spy).not.toHaveBeenCalled(); + }); + + test.each(entities)('%s rejects unauthenticated requests and a missing current user', async (entity) => { + const spy = jest.spyOn(Container.get(DeletionEligibilityService), entity); + env.google.authTokenRequired = true; + await request(app) + .post(route(entity)) + .send({ ids: [randomUUID()] }) + .expect(401); + env.google.authTokenRequired = false; + await db.getRepository(User).delete({ email: SYSTEM_USER_EMAIL }); + await request(app) + .post(route(entity)) + .send({ ids: [randomUUID()] }) + .expect(401); + expect(spy).not.toHaveBeenCalled(); + }); + + test.each(Object.values(UserRole))('uses server permissions for %s', async (role) => { + await db.getRepository(User).update(SYSTEM_USER_EMAIL, { role }); + for (const entity of entities) { + const [row] = await create(entity); + const { body } = await request(app) + .post(route(entity)) + .send({ + ids: [row.id], + role: UserRole.ADMIN, + canDelete: true, + stateOrStatus: 'unused', + }) + .expect(200); + const allowed = + role === UserRole.ADMIN || + role === UserRole.CREATOR || + (role === UserRole.USER_MANAGER && entity === 'segments'); + expect(body.allDeletable).toBe(allowed); + expect(body.items[0]).toMatchObject({ + id: row.id, + name: row.name, + availability: 'present', + canDelete: allowed, + }); + expect(body.items[0].reasonCode).toBe(allowed ? undefined : DeletionReasonCode.MISSING_PERMISSION); + } + }); + + test('normalizes experiment aliases and matches all existing menu states', async () => { + const states: [EXPERIMENT_STATE, string, boolean][] = [ + [EXPERIMENT_STATE.INACTIVE, 'inactive', true], + [EXPERIMENT_STATE.ENROLLING, 'running', true], + [EXPERIMENT_STATE.ENROLLMENT_COMPLETE, 'paused', true], + [EXPERIMENT_STATE.CANCELLED, 'completed', true], + [EXPERIMENT_STATE.ARCHIVED, 'archived', true], + [EXPERIMENT_STATE.DRAFT, 'draft', false], + [EXPERIMENT_STATE.PREVIEW, 'preview', false], + [EXPERIMENT_STATE.SCHEDULED, 'scheduled', false], + ]; + const rows = await create('experiments', states.length); + for (let i = 0; i < rows.length; i++) + await db.getRepository(Experiment).update(rows[i].id, { state: states[i][0] }); + const { body } = await request(app) + .post(route('experiments')) + .send({ ids: rows.map((row) => row.id) }) + .expect(200); + expect(body.allDeletable).toBe(false); + body.items.forEach((item, i) => { + expect(item.stateOrStatus).toBe(states[i][1]); + expect(item.canDelete).toBe(states[i][2]); + expect(item.reasonCode).toBe(states[i][2] ? undefined : DeletionReasonCode.EXPERIMENT_STATE_UNSUPPORTED); + }); + }); + + test('allows disabled/archived flags and observes a later enablement', async () => { + const rows = await create('flags', 3); + await db.getRepository(FeatureFlag).update(rows[1].id, { status: FEATURE_FLAG_STATUS.ARCHIVED }); + await db.getRepository(FeatureFlag).update(rows[2].id, { status: FEATURE_FLAG_STATUS.ENABLED }); + const ids = rows.map((row) => row.id); + const first = await request(app).post(route('flags')).send({ ids }).expect(200); + expect(first.body.items.map((item) => item.canDelete)).toEqual([true, true, false]); + expect(first.body.items[2].reasonCode).toBe(DeletionReasonCode.FEATURE_FLAG_ENABLED); + await db.getRepository(FeatureFlag).update(rows[0].id, { status: FEATURE_FLAG_STATUS.ENABLED }); + const second = await request(app) + .post(route('flags')) + .send({ ids: [ids[0]] }) + .expect(200); + expect(second.body.allDeletable).toBe(false); + expect(second.body.items[0].reasonCode).toBe(DeletionReasonCode.FEATURE_FLAG_ENABLED); + }); + + test.each(entities)('%s preserves input order and reports missing IDs without exposing details', async (entity) => { + const rows = await create(entity, 2); + const ids = [rows[1].id.toUpperCase(), randomUUID(), rows[0].id]; + const { body } = await request(app).post(route(entity)).send({ ids }).expect(200); + expect(body.items.map((item) => item.id)).toEqual(ids); + expect(body.items.map((item) => item.canDelete)).toEqual([true, false, true]); + expect(body.items[1]).toEqual({ + id: ids[1], + availability: 'not_found', + canDelete: false, + reasonCode: 'not_found', + }); + for (const item of body.items) { + expect( + Object.keys(item).every((key) => + ['id', 'availability', 'name', 'stateOrStatus', 'segmentType', 'canDelete', 'reasonCode'].includes(key) + ) + ).toBe(true); + } + expect(body.allDeletable).toBe(false); + }); + + test('includes hidden Used selections using the current UI private-list structure', async () => { + const [unused, child, parent] = await create('segments', 3); + // Same topology as Add List: public parent -> private Segment list -> public child. + const childList = await list('Child list', child); + await db.createQueryBuilder().relation(Segment, 'subSegments').of(parent.id).add(childList.id); + const ownerList = await list('Experiment list', parent); + const [experiment] = await create('experiments'); + await db + .getRepository(ExperimentSegmentInclusion) + .insert({ experimentId: experiment.id, segmentId: ownerList.id }); + await db.getRepository(IndividualForSegment).insert({ segmentId: unused.id, userId: 'do-not-return-members' }); + const spy = jest.spyOn(Container.get(SegmentService), 'getSegmentStatus'); + const { body } = await request(app) + .post(route('segments')) + .send({ + ids: [unused.id, child.id], + searchParams: { key: 'name', string: unused.name }, + }) + .expect(200); + expect(body.items.map((item) => item.canDelete)).toEqual([true, false]); + expect(body.items[1].reasonCode).toBe(DeletionReasonCode.SEGMENT_IN_USE); + expect(body.allDeletable).toBe(false); + expect(spy).toHaveBeenCalledTimes(1); + expect(spy.mock.calls[0][0].map((row) => row.id).sort()).toEqual([unused.id, child.id].sort()); + expect(await db.getRepository(IndividualForSegment).countBy({ segmentId: unused.id })).toBe(1); + }); + + test('preserves archived-experiment and disabled-flag reference rules', async () => { + const [archivedChild, flagChild] = await create('segments', 2); + const [experiment] = await create('experiments'); + await db.getRepository(Experiment).update(experiment.id, { state: EXPERIMENT_STATE.ARCHIVED }); + const experimentList = await list('Archived owner list', archivedChild); + await db + .getRepository(ExperimentSegmentInclusion) + .insert({ experimentId: experiment.id, segmentId: experimentList.id }); + const [flag] = await create('flags'); + const flagList = await list('Disabled flag list', flagChild); + await db.getRepository(FeatureFlagSegmentExclusion).insert({ + featureFlagId: flag.id, + segmentId: flagList.id, + enabled: false, + listType: STANDARD_LIST_TYPE.SEGMENT, + }); + const { body } = await request(app) + .post(route('segments')) + .send({ ids: [archivedChild.id, flagChild.id] }) + .expect(200); + expect(body.items.map((item) => item.canDelete)).toEqual([true, false]); + expect(body.items[1].reasonCode).toBe(DeletionReasonCode.SEGMENT_IN_USE); + }); + + test('rejects global/private segments and omits private-list metadata', async () => { + const rows = await create('segments', 2); + await db.getRepository(Segment).update(rows[0].id, { type: SEGMENT_TYPE.PRIVATE }); + await db.getRepository(Segment).update(rows[1].id, { type: SEGMENT_TYPE.GLOBAL_EXCLUDE }); + const spy = jest.spyOn(Container.get(SegmentService), 'getSegmentStatus'); + const { body } = await request(app) + .post(route('segments')) + .send({ ids: rows.map((row) => row.id) }) + .expect(200); + expect(body.items[0]).toEqual({ + id: rows[0].id, + availability: 'unavailable', + canDelete: false, + reasonCode: DeletionReasonCode.PROTECTED_SEGMENT_TYPE, + }); + expect(body.items[1]).toMatchObject({ + stateOrStatus: 'excluded', + canDelete: false, + reasonCode: DeletionReasonCode.PROTECTED_SEGMENT_TYPE, + }); + expect(body.allDeletable).toBe(false); + expect(spy).not.toHaveBeenCalled(); + }); + + test.each(entities.flatMap((entity) => [20, 100, 500].map((count) => ({ entity, count }))))( + '$entity accepts $count IDs in one read-only HTTP request with fixed query count', + async ({ entity, count }) => { + const rows = await create(entity, count); + const ids = rows.map((row) => row.id).reverse(); + const querySpy = jest.spyOn(db.logger, 'logQuery'); + const service = Container.get(SegmentService); + const statusSpy = jest.spyOn(service, 'getSegmentStatus'); + const detailsSpy = jest.spyOn(service, 'getSingleSegmentWithStatus'); + const membersSpy = jest.spyOn(service, 'getSegmentByIds'); + const { body } = await request(app).post(route(entity)).send({ ids }).expect(200); + expect(body.allDeletable).toBe(true); + expect(body.items.map((item) => item.id)).toEqual(ids); + const queries = querySpy.mock.calls.map(([sql]) => sql); + expect(queries.filter((sql) => /^(INSERT|UPDATE|DELETE|ALTER|CREATE)|FOR (UPDATE|SHARE)/i.test(sql))).toEqual( + [] + ); + // Includes the real auth check's user lookup and, for segments, BEGIN/COMMIT plus the five shared reads. + expect(queries).toHaveLength(entity === 'segments' ? 9 : 2); + expect(statusSpy).toHaveBeenCalledTimes(entity === 'segments' ? 1 : 0); + expect(detailsSpy).not.toHaveBeenCalled(); + expect(membersSpy).not.toHaveBeenCalled(); + } + ); + }); +} diff --git a/packages/backend/test/integration/index.test.ts b/packages/backend/test/integration/index.test.ts index e0cf9518e..69db3ead0 100644 --- a/packages/backend/test/integration/index.test.ts +++ b/packages/backend/test/integration/index.test.ts @@ -125,6 +125,7 @@ import { FeatureFlagInclusionExclusion } from './FeatureFlags'; import { ListValueFiltering } from './ListValueFiltering'; import { FeatureFlagDeleteCleanup, SegmentDeleteCleanup } from './DeleteCleanup'; import { registerSegmentDeletionGuardTests } from './SegmentDeletionGuard'; +import { registerDeletionEligibilityTests } from './DeletionEligibility'; describe('Integration Tests', () => { jest.setTimeout(100000000); @@ -135,6 +136,7 @@ describe('Integration Tests', () => { let defaultConnection: DataSource; let exportConnection: DataSource; registerSegmentDeletionGuardTests(() => [defaultConnection, exportConnection]); + registerDeletionEligibilityTests(() => [defaultConnection, exportConnection]); beforeAll(async () => { configureLogger(); [defaultConnection, exportConnection] = await createDatabaseConnection(); diff --git a/packages/backend/test/unit/controllers/ExperimentController.test.ts b/packages/backend/test/unit/controllers/ExperimentController.test.ts index f5990855f..d65ec7ec5 100644 --- a/packages/backend/test/unit/controllers/ExperimentController.test.ts +++ b/packages/backend/test/unit/controllers/ExperimentController.test.ts @@ -1,3 +1,4 @@ +import { DeletionEligibilityService } from '../../../src/api/services/batch/DeletionEligibilityService'; import app from '../../utils/expressApp'; import request from 'supertest'; import { configureLogger } from '../../utils/logger'; @@ -34,6 +35,7 @@ describe('Experiment Controller Testing', () => { configureLogger(); routingUseContainer(Container); classValidatorUseContainer(Container); + Container.set(DeletionEligibilityService, {} as DeletionEligibilityService); // set mock container Container.set(ExperimentService, new ExperimentServiceMock()); diff --git a/packages/backend/test/unit/controllers/FeatureFlagController.test.ts b/packages/backend/test/unit/controllers/FeatureFlagController.test.ts index 5708f7841..6c462ee8b 100644 --- a/packages/backend/test/unit/controllers/FeatureFlagController.test.ts +++ b/packages/backend/test/unit/controllers/FeatureFlagController.test.ts @@ -1,3 +1,4 @@ +import { DeletionEligibilityService } from '../../../src/api/services/batch/DeletionEligibilityService'; import app from '../../utils/expressApp'; import request from 'supertest'; import { configureLogger } from '../../utils/logger'; @@ -18,6 +19,7 @@ describe('Feature Flag Controller Testing', () => { configureLogger(); routingUseContainer(Container); classValidatorUseContainer(Container); + Container.set(DeletionEligibilityService, {} as DeletionEligibilityService); Container.set(FeatureFlagService, new FeatureFlagServiceMock()); Container.set(ExperimentUserService, new ExperimentUserServiceMock()); diff --git a/packages/backend/test/unit/controllers/SegmentController.test.ts b/packages/backend/test/unit/controllers/SegmentController.test.ts index fa3b6126e..36847d31e 100644 --- a/packages/backend/test/unit/controllers/SegmentController.test.ts +++ b/packages/backend/test/unit/controllers/SegmentController.test.ts @@ -1,3 +1,4 @@ +import { DeletionEligibilityService } from '../../../src/api/services/batch/DeletionEligibilityService'; import app from '../../utils/expressApp'; import request from 'supertest'; import { configureLogger } from '../../utils/logger'; @@ -14,6 +15,7 @@ describe('Segment Controller Testing', () => { configureLogger(); routingUseContainer(Container); classValidatorUseContainer(Container); + Container.set(DeletionEligibilityService, {} as DeletionEligibilityService); // set mock container Container.set(SegmentService, new SegmentServiceMock()); diff --git a/packages/backend/test/unit/services/batch/DeletionEligibilityService.test.ts b/packages/backend/test/unit/services/batch/DeletionEligibilityService.test.ts new file mode 100644 index 000000000..fa3be2cc1 --- /dev/null +++ b/packages/backend/test/unit/services/batch/DeletionEligibilityService.test.ts @@ -0,0 +1,98 @@ +import { randomUUID } from 'crypto'; +import { DataSource } from 'typeorm'; +import { + DeletionReasonCode, + EXPERIMENT_STATE, + FEATURE_FLAG_STATUS, + SEGMENT_STATUS, + SEGMENT_TYPE, + UserRole, +} from 'upgrade_types'; +import { DeletionEligibilityService } from '../../../../src/api/services/batch/DeletionEligibilityService'; +import { SegmentService } from '../../../../src/api/services/SegmentService'; + +const methods = ['experiments', 'flags', 'segments'] as const; + +describe('DeletionEligibilityService failure handling', () => { + const id = randomUUID(); + const admin = { email: 'test@example.com', firstName: 'Test', lastName: 'User', role: UserRole.ADMIN }; + let service: DeletionEligibilityService; + let find: jest.Mock; + let getMany: jest.Mock; + let getRepository: jest.Mock; + let getSegmentStatus: jest.Mock; + beforeEach(() => { + const row = { + id, + name: 'Selected', + state: EXPERIMENT_STATE.INACTIVE, + status: FEATURE_FLAG_STATUS.DISABLED, + type: SEGMENT_TYPE.PUBLIC, + subSegments: [], + }; + find = jest.fn().mockResolvedValue([row]); + getMany = jest.fn().mockResolvedValue([row]); + const builder = { + leftJoin: jest.fn().mockReturnThis(), + select: jest.fn().mockReturnThis(), + where: jest.fn().mockReturnThis(), + getMany, + }; + getRepository = jest.fn().mockReturnValue({ find, createQueryBuilder: () => builder }); + getSegmentStatus = jest.fn().mockResolvedValue({ segmentsData: [{ ...row, status: SEGMENT_STATUS.UNUSED }] }); + service = new DeletionEligibilityService( + { getRepository } as unknown as DataSource, + { getSegmentStatus } as unknown as SegmentService + ); + }); + + test.each(methods)('%s requires a current user before querying', async (method) => { + await expect(service[method]([id], undefined)).rejects.toMatchObject({ httpCode: 401 }); + expect(getRepository).not.toHaveBeenCalled(); + }); + + test.each(methods)('%s denies missing and unknown roles', async (method) => { + for (const role of [undefined, null, 'unknown' as UserRole]) { + const result = await service[method]([id], { ...admin, role }); + expect(result.allDeletable).toBe(false); + expect(result.items[0]).toMatchObject({ + availability: 'present', + canDelete: false, + reasonCode: DeletionReasonCode.MISSING_PERMISSION, + }); + } + }); + + test.each(methods)('%s propagates database failure instead of reporting absence', async (method) => { + const failure = new Error('Database unavailable'); + find.mockRejectedValue(failure); + getMany.mockRejectedValue(failure); + await expect(service[method]([id], admin)).rejects.toBe(failure); + expect(getSegmentStatus).not.toHaveBeenCalled(); + }); + + test('propagates a failed shared segment status read', async () => { + const failure = new Error('Status read failed'); + getSegmentStatus.mockRejectedValue(failure); + await expect(service.segments([id], admin)).rejects.toBe(failure); + }); + + test('does not declare an existing segment deletable when its derived status is unavailable', async () => { + getSegmentStatus.mockResolvedValue({ segmentsData: [] }); + const result = await service.segments([id], admin); + expect(result.items[0]).toMatchObject({ + id, + availability: 'unavailable', + canDelete: false, + reasonCode: DeletionReasonCode.ELIGIBILITY_UNAVAILABLE, + }); + expect(result.allDeletable).toBe(false); + }); + + test('does not run global status reads when no selected segment exists', async () => { + getMany.mockResolvedValue([]); + const result = await service.segments([id], admin); + expect(result.items[0]).toMatchObject({ availability: 'not_found', canDelete: false }); + expect(getSegmentStatus).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/types/src/BatchActions/index.ts b/packages/types/src/BatchActions/index.ts new file mode 100644 index 000000000..e882bfeca --- /dev/null +++ b/packages/types/src/BatchActions/index.ts @@ -0,0 +1,57 @@ +import { EXPERIMENT_STATE, FEATURE_FLAG_STATUS, SEGMENT_STATUS, SEGMENT_TYPE } from '../Experiment/enums'; + +export interface BatchEntityIdsRequest { + ids: string[]; +} + +export enum DeletionReasonCode { + NOT_FOUND = 'not_found', + MISSING_PERMISSION = 'missing_permission', + EXPERIMENT_STATE_UNSUPPORTED = 'experiment_state_unsupported', + FEATURE_FLAG_ENABLED = 'feature_flag_enabled', + FEATURE_FLAG_STATUS_UNSUPPORTED = 'feature_flag_status_unsupported', + SEGMENT_IN_USE = 'segment_in_use', + PROTECTED_SEGMENT_TYPE = 'protected_segment_type', + ELIGIBILITY_UNAVAILABLE = 'eligibility_unavailable', + DELETE_FAILED = 'delete_failed', + LOCK_TIMEOUT = 'lock_timeout', + EXTERNAL_SYNC_FAILED = 'external_sync_failed', + OUTCOME_UNKNOWN = 'outcome_unknown', +} + +export interface DeletionEligibilityItem { + id: string; + availability: 'present' | 'not_found' | 'unavailable'; + name?: string; + stateOrStatus?: EXPERIMENT_STATE | FEATURE_FLAG_STATUS | SEGMENT_STATUS; + segmentType?: SEGMENT_TYPE; + canDelete: boolean; + reasonCode?: DeletionReasonCode; +} + +export interface DeletionEligibilityResult { + items: DeletionEligibilityItem[]; + allDeletable: boolean; +} + +export type BatchDeleteRequest = BatchEntityIdsRequest; + +export type BatchDeleteItemOutcome = + | 'deleted' + | 'not_found' + | 'ineligible' + | 'forbidden' + | 'failed' + | 'unknown' + | 'not_attempted'; + +export interface BatchDeleteItemResult { + id: string; + outcome: BatchDeleteItemOutcome; + reasonCode?: DeletionReasonCode; +} + +export interface BatchDeleteResult { + phase: 'rejected' | 'executed'; + results: BatchDeleteItemResult[]; +} diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index 4133ce13e..c5635d0cf 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -94,6 +94,16 @@ export { ExperimentQueryComparator, } from './Experiment/interfaces'; export { SYSTEM_USER_EMAIL, DEV_USER_EMAIL, FAKE_DEV_CREDENTIAL } from './User'; +export { + BatchEntityIdsRequest, + DeletionReasonCode, + DeletionEligibilityItem, + DeletionEligibilityResult, + BatchDeleteRequest, + BatchDeleteItemOutcome, + BatchDeleteItemResult, + BatchDeleteResult, +} from './BatchActions'; export { Prior, CurrentPosteriors, From 602b426cd270dc350345128456c54018c1b28441 Mon Sep 17 00:00:00 2001 From: Zack Lee Date: Thu, 10 Sep 2026 12:34:14 -0400 Subject: [PATCH 03/61] feat: add guarded batch deletion endpoints with per-item outcomes --- .../api/controllers/ExperimentController.ts | 42 +- .../api/controllers/FeatureFlagController.ts | 42 +- .../src/api/controllers/SegmentController.ts | 69 ++- .../src/api/services/ExperimentService.ts | 12 +- .../src/api/services/FeatureFlagService.ts | 7 +- .../api/services/MoocletExperimentService.ts | 6 +- .../src/api/services/SegmentService.ts | 7 +- .../api/services/batch/BatchDeleteService.ts | 269 +++++++++++ .../batch/DeletionEligibilityService.ts | 42 +- .../backend/src/types/DeletionTransaction.ts | 4 + .../test/integration/BatchDelete/index.ts | 425 ++++++++++++++++++ .../backend/test/integration/index.test.ts | 2 + .../controllers/ExperimentController.test.ts | 2 + .../controllers/FeatureFlagController.test.ts | 2 + .../controllers/SegmentController.test.ts | 2 + .../services/batch/BatchDeleteService.test.ts | 350 +++++++++++++++ packages/types/src/BatchActions/index.ts | 43 +- packages/types/src/index.ts | 5 + 18 files changed, 1286 insertions(+), 45 deletions(-) create mode 100644 packages/backend/src/api/services/batch/BatchDeleteService.ts create mode 100644 packages/backend/src/types/DeletionTransaction.ts create mode 100644 packages/backend/test/integration/BatchDelete/index.ts create mode 100644 packages/backend/test/unit/services/batch/BatchDeleteService.test.ts diff --git a/packages/backend/src/api/controllers/ExperimentController.ts b/packages/backend/src/api/controllers/ExperimentController.ts index 78e77b78f..4012d9911 100644 --- a/packages/backend/src/api/controllers/ExperimentController.ts +++ b/packages/backend/src/api/controllers/ExperimentController.ts @@ -1,4 +1,6 @@ -import { DeletionEligibilityResult } from 'upgrade_types'; +import { DeletionEligibilityResult, BatchDeleteResult } from 'upgrade_types'; +import { Inject } from 'typedi'; +import { BatchDeleteService } from '../services/batch/BatchDeleteService'; import { BatchEntityIdsValidator } from './validators/BatchEntityIdsValidator'; import { DeletionEligibilityService } from '../services/batch/DeletionEligibilityService'; import { @@ -668,7 +670,8 @@ export class ExperimentController { public moocletRewardService: MoocletRewardsService, public importExportService: ImportExportService, public cacheService: CacheService, - private deletionEligibilityService: DeletionEligibilityService + private deletionEligibilityService: DeletionEligibilityService, + @Inject(() => BatchDeleteService) private batchDeleteService: BatchDeleteService ) {} /** @@ -703,6 +706,41 @@ export class ExperimentController { return this.deletionEligibilityService.experiments(ids, currentUser); } + /** + * @swagger + * /experiments/batch-delete: + * post: + * summary: Delete the selected experiments + * description: Checks the entire selection before mutation, then deletes sequentially. Stops after the first failure and returns one result per ID. + * tags: + * - Experiments + * parameters: + * - in: body + * name: selection + * required: true + * schema: + * $ref: '#/definitions/BatchEntityIdsRequest' + * responses: + * '200': + * description: Inspect phase and per-ID outcomes; rejected means no deletions were performed. + * schema: + * $ref: '#/definitions/BatchDeleteResult' + * '400': + * description: Expected a nonempty array of unique UUIDs. + * '401': + * description: A current authenticated user is required. + * '403': + * description: The current user cannot delete this entity type. + */ + @Post('/batch-delete') + public batchDelete( + @Body({ validate: true }) { ids }: BatchEntityIdsValidator, + @CurrentUser({ required: true }) currentUser: UserDTO, + @Req() request: AppRequest + ): Promise { + return this.batchDeleteService.delete('experiments', ids, currentUser, request.logger); + } + /** * @swagger * /experiments/names: diff --git a/packages/backend/src/api/controllers/FeatureFlagController.ts b/packages/backend/src/api/controllers/FeatureFlagController.ts index 307b9f68a..9947bc8be 100644 --- a/packages/backend/src/api/controllers/FeatureFlagController.ts +++ b/packages/backend/src/api/controllers/FeatureFlagController.ts @@ -1,4 +1,6 @@ -import { DeletionEligibilityResult } from 'upgrade_types'; +import { DeletionEligibilityResult, BatchDeleteResult } from 'upgrade_types'; +import { Inject } from 'typedi'; +import { BatchDeleteService } from '../services/batch/BatchDeleteService'; import { BatchEntityIdsValidator } from './validators/BatchEntityIdsValidator'; import { DeletionEligibilityService } from '../services/batch/DeletionEligibilityService'; import { @@ -160,7 +162,8 @@ export class FeatureFlagsController { constructor( public featureFlagService: FeatureFlagService, public experimentUserService: ExperimentUserService, - private deletionEligibilityService: DeletionEligibilityService + private deletionEligibilityService: DeletionEligibilityService, + @Inject(() => BatchDeleteService) private batchDeleteService: BatchDeleteService ) {} /** @@ -195,6 +198,41 @@ export class FeatureFlagsController { return this.deletionEligibilityService.flags(ids, currentUser); } + /** + * @swagger + * /flags/batch-delete: + * post: + * summary: Delete the selected flags + * description: Checks the entire selection before mutation, then deletes sequentially. Stops after the first failure and returns one result per ID. + * tags: + * - Feature Flags + * parameters: + * - in: body + * name: selection + * required: true + * schema: + * $ref: '#/definitions/BatchEntityIdsRequest' + * responses: + * '200': + * description: Inspect phase and per-ID outcomes; rejected means no deletions were performed. + * schema: + * $ref: '#/definitions/BatchDeleteResult' + * '400': + * description: Expected a nonempty array of unique UUIDs. + * '401': + * description: A current authenticated user is required. + * '403': + * description: The current user cannot delete this entity type. + */ + @Post('/batch-delete') + public batchDelete( + @Body({ validate: true }) { ids }: BatchEntityIdsValidator, + @CurrentUser({ required: true }) currentUser: UserDTO, + @Req() request: AppRequest + ): Promise { + return this.batchDeleteService.delete('flags', ids, currentUser, request.logger); + } + /** * @swagger * /flags: diff --git a/packages/backend/src/api/controllers/SegmentController.ts b/packages/backend/src/api/controllers/SegmentController.ts index 444d7db51..8b06b8486 100644 --- a/packages/backend/src/api/controllers/SegmentController.ts +++ b/packages/backend/src/api/controllers/SegmentController.ts @@ -1,5 +1,7 @@ import { UserDTO } from '../DTO/UserDTO'; -import { DeletionEligibilityResult } from 'upgrade_types'; +import { DeletionEligibilityResult, BatchDeleteResult } from 'upgrade_types'; +import { Inject } from 'typedi'; +import { BatchDeleteService } from '../services/batch/BatchDeleteService'; import { BatchEntityIdsValidator } from './validators/BatchEntityIdsValidator'; import { DeletionEligibilityService } from '../services/batch/DeletionEligibilityService'; import { @@ -54,6 +56,30 @@ interface SegmentPaginationInfo extends PaginationResponse { /** * @swagger * definitions: + * BatchDeleteItemResult: + * type: object + * required: [id, outcome] + * properties: + * id: + * type: string + * format: uuid + * outcome: + * type: string + * enum: [deleted, not_found, ineligible, forbidden, failed, unknown, not_attempted] + * reasonCode: + * type: string + * enum: [not_found, missing_permission, experiment_state_unsupported, feature_flag_enabled, feature_flag_status_unsupported, segment_in_use, protected_segment_type, eligibility_unavailable, delete_failed, lock_timeout, external_sync_failed, outcome_unknown, post_delete_failed, batch_budget_exceeded] + * BatchDeleteResult: + * type: object + * required: [phase, results] + * properties: + * phase: + * type: string + * enum: [rejected, executed] + * results: + * type: array + * items: + * $ref: '#/definitions/BatchDeleteItemResult' * BatchEntityIdsRequest: * type: object * required: [ids] @@ -281,7 +307,11 @@ interface SegmentPaginationInfo extends PaginationResponse { @Authorized() @JsonController('/segments') export class SegmentController { - constructor(public segmentService: SegmentService, private deletionEligibilityService: DeletionEligibilityService) {} + constructor( + public segmentService: SegmentService, + private deletionEligibilityService: DeletionEligibilityService, + @Inject(() => BatchDeleteService) private batchDeleteService: BatchDeleteService + ) {} /** * @swagger @@ -315,6 +345,41 @@ export class SegmentController { return this.deletionEligibilityService.segments(ids, currentUser); } + /** + * @swagger + * /segments/batch-delete: + * post: + * summary: Delete the selected segments + * description: Checks the entire selection before mutation, then deletes sequentially. Stops after the first failure and returns one result per ID. + * tags: + * - Segment + * parameters: + * - in: body + * name: selection + * required: true + * schema: + * $ref: '#/definitions/BatchEntityIdsRequest' + * responses: + * '200': + * description: Inspect phase and per-ID outcomes; rejected means no deletions were performed. + * schema: + * $ref: '#/definitions/BatchDeleteResult' + * '400': + * description: Expected a nonempty array of unique UUIDs. + * '401': + * description: A current authenticated user is required. + * '403': + * description: The current user cannot delete this entity type. + */ + @Post('/batch-delete') + public batchDelete( + @Body({ validate: true }) { ids }: BatchEntityIdsValidator, + @CurrentUser({ required: true }) currentUser: UserDTO, + @Req() request: AppRequest + ): Promise { + return this.batchDeleteService.delete('segments', ids, currentUser, request.logger); + } + /** * @swagger * /segments: diff --git a/packages/backend/src/api/services/ExperimentService.ts b/packages/backend/src/api/services/ExperimentService.ts index 640070ee0..ba20d690a 100644 --- a/packages/backend/src/api/services/ExperimentService.ts +++ b/packages/backend/src/api/services/ExperimentService.ts @@ -107,6 +107,7 @@ import { MoocletExperimentRefRepository } from '../repositories/MoocletExperimen import { ExperimentAuditLog } from '../models/ExperimentAuditLog'; import { SegmentRepository } from '../repositories/SegmentRepository'; import { NotFoundException } from '@nestjs/common/exceptions'; +import { DeletionTransaction } from '../../types/DeletionTransaction'; const errorRemovePart = 'An instance of ExperimentDTO has failed the validation:\n - '; const stratificationErrorMessage = @@ -427,14 +428,19 @@ export class ExperimentService { public async delete( experimentId: string, currentUser: UserDTO, - options?: { logger?: UpgradeLogger; existingEntityManager?: EntityManager } + options?: { + logger?: UpgradeLogger; + existingEntityManager?: EntityManager; + executeTransaction?: DeletionTransaction; + } ): Promise { - const { logger, existingEntityManager } = options; + const { logger, existingEntityManager, executeTransaction } = options; if (logger) { logger.info({ message: `Delete experiment => ${experimentId}` }); } const entityManager = existingEntityManager || this.dataSource.manager; - return await entityManager.transaction(async (transactionalEntityManager) => { + const transaction: DeletionTransaction = executeTransaction || ((work) => entityManager.transaction(work)); + return await transaction(async (transactionalEntityManager) => { const experiment = await this.experimentRepository.findOneExperiment(experimentId); if (experiment) { diff --git a/packages/backend/src/api/services/FeatureFlagService.ts b/packages/backend/src/api/services/FeatureFlagService.ts index e4742b89f..32d8a9c5d 100644 --- a/packages/backend/src/api/services/FeatureFlagService.ts +++ b/packages/backend/src/api/services/FeatureFlagService.ts @@ -60,6 +60,7 @@ import { NotFoundException } from '@nestjs/common/exceptions'; import { CacheService } from './CacheService'; import { FeatureFlagPrecomputedSegmentService, precomputedGroupKey } from './FeatureFlagPrecomputedSegmentService'; import { EntitySegmentResolutionInput } from '../../types'; +import { DeletionTransaction } from '../../types/DeletionTransaction'; import { SegmentFile, SegmentInputValidator } from '../controllers/validators/SegmentInputValidator'; import dayjs from 'dayjs'; import { getDateRangeNames } from '../repositories/utils/dateQuery'; @@ -342,10 +343,12 @@ export class FeatureFlagService { public async delete( featureFlagId: string, currentUser: UserDTO, - logger: UpgradeLogger + logger: UpgradeLogger, + executeTransaction?: DeletionTransaction ): Promise { logger.info({ message: `Delete Feature Flag => ${featureFlagId}` }); - return await this.dataSource.transaction(async (transactionalEntityManager) => { + const transaction: DeletionTransaction = executeTransaction || ((work) => this.dataSource.transaction(work)); + return await transaction(async (transactionalEntityManager) => { const featureFlag = await this.findOneForDetails(featureFlagId, logger); if (featureFlag) { diff --git a/packages/backend/src/api/services/MoocletExperimentService.ts b/packages/backend/src/api/services/MoocletExperimentService.ts index 3133a51d3..d59c62a7e 100644 --- a/packages/backend/src/api/services/MoocletExperimentService.ts +++ b/packages/backend/src/api/services/MoocletExperimentService.ts @@ -59,6 +59,7 @@ import { ExperimentCondition } from '../models/ExperimentCondition'; import { MetricService } from './MetricService'; import { env } from '../../env'; import { ExperimentSchedulerService } from './ExperimentSchedulerService'; +import { DeletionTransaction } from '../../types/DeletionTransaction'; export interface SyncCreateParams { experimentDTO: ExperimentDTO; @@ -215,8 +216,9 @@ export class MoocletExperimentService extends ExperimentService { return experiment; } - public async syncDelete(params: SyncDeleteParams): Promise { - return this.dataSource.transaction((manager) => this.handleDeleteMoocletTransaction(manager, params)); + public async syncDelete(params: SyncDeleteParams, executeTransaction?: DeletionTransaction): Promise { + const transaction: DeletionTransaction = executeTransaction || ((work) => this.dataSource.transaction(work)); + return transaction((manager) => this.handleDeleteMoocletTransaction(manager, params)); } /** diff --git a/packages/backend/src/api/services/SegmentService.ts b/packages/backend/src/api/services/SegmentService.ts index 316dfbf2a..eb43e3801 100644 --- a/packages/backend/src/api/services/SegmentService.ts +++ b/packages/backend/src/api/services/SegmentService.ts @@ -18,6 +18,7 @@ import { normalizeStandardListType, } from 'upgrade_types'; import { EntityManager, DataSource, Not, In } from 'typeorm'; +import { DeletionTransaction } from '../../types/DeletionTransaction'; import Papa from 'papaparse'; import { env } from '../../env'; @@ -538,7 +539,8 @@ export class SegmentService { public async deleteSegment( id: string, logger: UpgradeLogger, - beforeDelete?: (manager: EntityManager) => Promise + beforeDelete?: (manager: EntityManager) => Promise, + executeTransaction?: DeletionTransaction ): Promise { logger.info({ message: `Delete segment by id. segmentId: ${id}` }); @@ -548,12 +550,13 @@ export class SegmentService { // transaction below commits. Flags use withRecompute, which enforces the same // resolve-before -> delete -> recompute-after ordering internally. const affectedExperimentIds = await this.experimentPrecomputedSegmentService.getAffectedExperimentIds(id); + const transaction: DeletionTransaction = executeTransaction || ((work) => this.dataSource.transaction(work)); const deletedSegment = await this.featureFlagPrecomputedSegmentService.withRecompute( logger, () => this.featureFlagPrecomputedSegmentService.getAffectedFlagIds(id), () => - this.dataSource.transaction(async (transactionalEntityManager) => { + transaction(async (transactionalEntityManager) => { await beforeDelete?.(transactionalEntityManager); return this.deleteSegmentAndPrivateSubsegments(id, logger, transactionalEntityManager); }) diff --git a/packages/backend/src/api/services/batch/BatchDeleteService.ts b/packages/backend/src/api/services/batch/BatchDeleteService.ts new file mode 100644 index 000000000..1be133835 --- /dev/null +++ b/packages/backend/src/api/services/batch/BatchDeleteService.ts @@ -0,0 +1,269 @@ +import { performance } from 'perf_hooks'; +import { Inject, Service } from 'typedi'; +import { DataSource, EntityManager } from 'typeorm'; +import { ForbiddenError, UnauthorizedError } from 'routing-controllers'; +import { + BatchDeleteEntity, + BatchDeleteItemResult, + BatchDeleteResult, + DeletionEligibilityItem, + DeletionEligibilityResult, + DeletionReasonCode, + getExperimentDeletionReason, + getFlagDeletionReason, + hasBatchDeletePermission, +} from 'upgrade_types'; +import { env } from '../../../env'; +import { UpgradeLogger } from '../../../lib/logger/UpgradeLogger'; +import { InjectDataSource } from '../../../typeorm-typedi-extensions'; +import { DeletionTransaction } from '../../../types/DeletionTransaction'; +import { UserDTO } from '../../DTO/UserDTO'; +import { MoocletError } from '../../errors/MoocletError'; +import { Experiment } from '../../models/Experiment'; +import { FeatureFlag } from '../../models/FeatureFlag'; +import { ExperimentService } from '../ExperimentService'; +import { FeatureFlagService } from '../FeatureFlagService'; +import { MoocletExperimentService } from '../MoocletExperimentService'; +import { SegmentService } from '../SegmentService'; +import { DeletionEligibilityService } from './DeletionEligibilityService'; +import { assertSegmentDeletionAllowed, SegmentDeletionBlockedError } from './SegmentDeletionGuard'; + +// Admission budget: never abandon an in-flight deletion or claim it has been cancelled. +// Check before starting another item and again after acquiring its eligibility locks. +export const BATCH_DELETE_START_BUDGET_MS = 60_000; + +class DeletionBlockedError extends Error { + constructor(public readonly result: BatchDeleteItemResult) { + super(result.reasonCode); + } +} + +@Service() +export class BatchDeleteService { + constructor( + @InjectDataSource() private dataSource: DataSource, + @Inject(() => DeletionEligibilityService) private eligibility: DeletionEligibilityService, + @Inject(() => ExperimentService) private experiments: ExperimentService, + @Inject(() => FeatureFlagService) private flags: FeatureFlagService, + @Inject(() => SegmentService) private segments: SegmentService, + @Inject(() => MoocletExperimentService) private mooclets: MoocletExperimentService + ) {} + + public async delete( + entity: BatchDeleteEntity, + ids: string[], + user: UserDTO, + logger: UpgradeLogger + ): Promise { + if (!user) throw new UnauthorizedError('A current user is required'); + if (!hasBatchDeletePermission(user.role, entity)) throw new ForbiddenError('Deletion permission is required'); + const deadline = performance.now() + BATCH_DELETE_START_BUDGET_MS; + let preflight: DeletionEligibilityResult; + try { + preflight = await this.eligibility[entity](ids, user); + } catch (error) { + logger.error({ message: 'Batch deletion preflight failed', entity, error }); + return { + phase: 'rejected', + results: ids.map((id) => ({ + id, + outcome: 'not_attempted', + reasonCode: DeletionReasonCode.ELIGIBILITY_UNAVAILABLE, + })), + }; + } + if (!preflight.allDeletable) { + return { phase: 'rejected', results: preflight.items.map((item) => this.preflightResult(item)) }; + } + + const results: BatchDeleteItemResult[] = []; + let phase: BatchDeleteResult['phase'] = 'rejected'; + for (const id of ids) { + if (performance.now() >= deadline) { + results.push( + ...ids.slice(results.length).map( + (remaining): BatchDeleteItemResult => ({ + id: remaining, + outcome: 'not_attempted', + reasonCode: DeletionReasonCode.BATCH_BUDGET_EXCEEDED, + }) + ) + ); + break; + } + phase = 'executed'; + const result = await this.deleteOne(entity, id, user, logger, deadline); + results.push(result); + // A committed item with a post-delete failure must not be retried, but still stops this batch. + if (result.outcome !== 'deleted' || result.reasonCode) { + results.push( + ...ids.slice(results.length).map( + (remaining): BatchDeleteItemResult => ({ + id: remaining, + outcome: 'not_attempted', + }) + ) + ); + break; + } + } + return { phase, results }; + } + + private preflightResult(item: DeletionEligibilityItem): BatchDeleteItemResult { + if (item.canDelete) return { id: item.id, outcome: 'not_attempted' }; + const outcome = + item.availability === 'not_found' + ? 'not_found' + : item.reasonCode === DeletionReasonCode.MISSING_PERMISSION + ? 'forbidden' + : 'ineligible'; + return { id: item.id, outcome, reasonCode: item.reasonCode || DeletionReasonCode.ELIGIBILITY_UNAVAILABLE }; + } + + private async guard(entity: BatchDeleteEntity, id: string, manager: EntityManager): Promise { + if (entity === 'segments') return; // The existing segment hook runs inside this same transaction. + const row = + entity === 'experiments' + ? await manager + .getRepository(Experiment) + .findOne({ where: { id }, select: { id: true, state: true }, lock: { mode: 'pessimistic_write' } }) + : await manager + .getRepository(FeatureFlag) + .findOne({ where: { id }, select: { id: true, status: true }, lock: { mode: 'pessimistic_write' } }); + if (!row) throw new DeletionBlockedError({ id, outcome: 'not_found', reasonCode: DeletionReasonCode.NOT_FOUND }); + const reason = + entity === 'experiments' + ? getExperimentDeletionReason((row as Experiment).state) + : getFlagDeletionReason((row as FeatureFlag).status); + if (reason) throw new DeletionBlockedError({ id, outcome: 'ineligible', reasonCode: reason }); + } + + private async deleteOne( + entity: BatchDeleteEntity, + id: string, + user: UserDTO, + logger: UpgradeLogger, + deadline: number + ): Promise { + let committed = false; + let rolledBack = false; + let commitAttempted = false; + let mutationStarted = false; + const executeTransaction: DeletionTransaction = async (work: (manager: EntityManager) => Promise) => { + const runner = this.dataSource.createQueryRunner(); + let transactionError: unknown; + let response: T; + try { + await runner.connect(); + await runner.startTransaction('READ COMMITTED'); + await runner.manager.query(`SELECT set_config('lock_timeout', CASE + WHEN current_setting('lock_timeout')::interval = interval '0' + OR current_setting('lock_timeout')::interval > interval '5 seconds' + THEN '5s' ELSE current_setting('lock_timeout') END, true)`); + await this.guard(entity, id, runner.manager); + if (performance.now() >= deadline) { + throw new DeletionBlockedError({ + id, + outcome: 'not_attempted', + reasonCode: DeletionReasonCode.BATCH_BUDGET_EXCEEDED, + }); + } + mutationStarted = true; + response = await work(runner.manager); + // Legacy experiment/flag repositories return arrays despite their declared return types. + const deleted = Array.isArray(response) ? response[0] : response; + if (!deleted || (deleted as { id?: string }).id?.toLowerCase() !== id.toLowerCase()) { + throw new Error('Deletion did not return the guarded target'); + } + commitAttempted = true; + await runner.commitTransaction(); + committed = true; + } catch (error) { + transactionError = error; + if (runner.isTransactionActive) { + try { + await runner.rollbackTransaction(); + rolledBack = true; + } catch (rollbackError) { + logger.error({ message: 'Batch deletion rollback could not be confirmed', id, rollbackError }); + } + } + } finally { + try { + await runner.release(); + } catch (releaseError) { + logger.error({ message: 'Batch deletion connection release failed', id, releaseError }); + // Preserve the transaction failure if connection cleanup also fails. + if (!transactionError) transactionError = releaseError; + } + } + if (transactionError) throw transactionError; + return response; + }; + try { + if (entity === 'experiments') { + const ref = env.mooclets.enabled + ? await this.mooclets.getMoocletExperimentRefByUpgradeExperimentId(id) + : undefined; + if (ref) + await this.mooclets.syncDelete( + { moocletExperimentRef: ref, experimentId: id, currentUser: user, logger }, + executeTransaction + ); + else await this.experiments.delete(id, user, { logger, executeTransaction }); + } else if (entity === 'flags') { + await this.flags.delete(id, user, logger, executeTransaction); + } else { + await this.segments.deleteSegment( + id, + logger, + async (manager) => { + await assertSegmentDeletionAllowed(id, manager); + if (performance.now() >= deadline) { + throw new DeletionBlockedError({ + id, + outcome: 'not_attempted', + reasonCode: DeletionReasonCode.BATCH_BUDGET_EXCEEDED, + }); + } + }, + executeTransaction + ); + } + return committed + ? { id, outcome: 'deleted' } + : { id, outcome: 'unknown', reasonCode: DeletionReasonCode.OUTCOME_UNKNOWN }; + } catch (error) { + logger.error({ message: 'Batch deletion item failed', entity, id, committed, error }); + if (committed) return { id, outcome: 'deleted', reasonCode: DeletionReasonCode.POST_DELETE_FAILED }; + // A failed COMMIT can mean the server committed but its acknowledgment was lost. + if (commitAttempted || (mutationStarted && !rolledBack)) { + return { id, outcome: 'unknown', reasonCode: DeletionReasonCode.OUTCOME_UNKNOWN }; + } + if (error instanceof DeletionBlockedError) return error.result; + if (error instanceof SegmentDeletionBlockedError) { + return { + id, + outcome: error.reason === 'missing' ? 'not_found' : 'ineligible', + reasonCode: + error.reason === 'missing' + ? DeletionReasonCode.NOT_FOUND + : error.reason === 'protected' + ? DeletionReasonCode.PROTECTED_SEGMENT_TYPE + : DeletionReasonCode.SEGMENT_IN_USE, + }; + } + return { + id, + outcome: 'failed', + reasonCode: + error instanceof MoocletError + ? DeletionReasonCode.EXTERNAL_SYNC_FAILED + : (error as { code?: string })?.code === '55P03' + ? DeletionReasonCode.LOCK_TIMEOUT + : DeletionReasonCode.DELETE_FAILED, + }; + } + } +} diff --git a/packages/backend/src/api/services/batch/DeletionEligibilityService.ts b/packages/backend/src/api/services/batch/DeletionEligibilityService.ts index a30c16b8c..08f2c68c8 100644 --- a/packages/backend/src/api/services/batch/DeletionEligibilityService.ts +++ b/packages/backend/src/api/services/batch/DeletionEligibilityService.ts @@ -1,16 +1,16 @@ -import { Service } from 'typedi'; +import { Inject, Service } from 'typedi'; import { DataSource, In } from 'typeorm'; import { UnauthorizedError } from 'routing-controllers'; import { DeletionEligibilityItem, DeletionEligibilityResult, DeletionReasonCode, - EXPERIMENT_STATE, - EXPERIMENT_STATE_DISPLAY_NAME_OVERRIDES, - FEATURE_FLAG_STATUS, + getExperimentDeletionState, + getExperimentDeletionReason, + getFlagDeletionReason, + hasBatchDeletePermission, SEGMENT_STATUS, SEGMENT_TYPE, - UserRole, } from 'upgrade_types'; import { InjectDataSource } from '../../../typeorm-typedi-extensions'; import { UserDTO } from '../../DTO/UserDTO'; @@ -21,18 +21,13 @@ import { SegmentService, SegmentWithStatus } from '../SegmentService'; type EligibilitySummary = Omit; -const deletableExperimentStates = new Set([ - EXPERIMENT_STATE.INACTIVE, - EXPERIMENT_STATE.RUNNING, - EXPERIMENT_STATE.PAUSED, - EXPERIMENT_STATE.COMPLETED, - EXPERIMENT_STATE.ARCHIVED, -]); - /** Read-only batch checks, also used by the initial batch-delete preflight. */ @Service() export class DeletionEligibilityService { - constructor(@InjectDataSource() private dataSource: DataSource, private segmentService: SegmentService) {} + constructor( + @InjectDataSource() private dataSource: DataSource, + @Inject(() => SegmentService) private segmentService: SegmentService + ) {} public async experiments(ids: string[], user: UserDTO): Promise { const permission = this.permissionReason(user); @@ -41,14 +36,12 @@ export class DeletionEligibilityService { select: { id: true, name: true, state: true }, }); return this.result(ids, rows, (row) => { - const state = EXPERIMENT_STATE_DISPLAY_NAME_OVERRIDES[row.state] || row.state; + const state = getExperimentDeletionState(row.state); return { availability: 'present', name: row.name, stateOrStatus: state, - reasonCode: - permission || - (deletableExperimentStates.has(state) ? undefined : DeletionReasonCode.EXPERIMENT_STATE_UNSUPPORTED), + reasonCode: permission || getExperimentDeletionReason(row.state), }; }); } @@ -63,13 +56,7 @@ export class DeletionEligibilityService { availability: 'present', name: row.name, stateOrStatus: row.status, - reasonCode: - permission || - (row.status === FEATURE_FLAG_STATUS.ENABLED - ? DeletionReasonCode.FEATURE_FLAG_ENABLED - : [FEATURE_FLAG_STATUS.DISABLED, FEATURE_FLAG_STATUS.ARCHIVED].includes(row.status) - ? undefined - : DeletionReasonCode.FEATURE_FLAG_STATUS_UNSUPPORTED), + reasonCode: permission || getFlagDeletionReason(row.status), })); } @@ -114,10 +101,7 @@ export class DeletionEligibilityService { private permissionReason(user: UserDTO, segments = false): DeletionReasonCode | undefined { if (!user) throw new UnauthorizedError('A current user is required'); - const allowed = - user.role === UserRole.ADMIN || - user.role === UserRole.CREATOR || - (segments && user.role === UserRole.USER_MANAGER); + const allowed = hasBatchDeletePermission(user.role, segments ? 'segments' : 'experiments'); return allowed ? undefined : DeletionReasonCode.MISSING_PERMISSION; } diff --git a/packages/backend/src/types/DeletionTransaction.ts b/packages/backend/src/types/DeletionTransaction.ts new file mode 100644 index 000000000..a5f191cbf --- /dev/null +++ b/packages/backend/src/types/DeletionTransaction.ts @@ -0,0 +1,4 @@ +import { EntityManager } from 'typeorm'; + +/** Optional internal transaction boundary; ordinary deletion callers retain their existing behavior. */ +export type DeletionTransaction = (work: (manager: EntityManager) => Promise) => Promise; diff --git a/packages/backend/test/integration/BatchDelete/index.ts b/packages/backend/test/integration/BatchDelete/index.ts new file mode 100644 index 000000000..f3a4e1cf4 --- /dev/null +++ b/packages/backend/test/integration/BatchDelete/index.ts @@ -0,0 +1,425 @@ +import { randomUUID } from 'crypto'; +import { performance } from 'perf_hooks'; +import { Application } from 'express'; +import request from 'supertest'; +import Container from 'typedi'; +import { createExpressServer } from 'routing-controllers'; +import { DataSource, In } from 'typeorm'; +import { + ASSIGNMENT_UNIT, + BatchDeleteEntity, + CACHE_PREFIX, + DeletionReasonCode, + EXPERIMENT_STATE, + FEATURE_FLAG_STATUS, + POST_EXPERIMENT_RULE, + SEGMENT_TYPE, + STANDARD_LIST_TYPE, + SYSTEM_USER_EMAIL, + UserRole, +} from 'upgrade_types'; +import { ExperimentController } from '../../../src/api/controllers/ExperimentController'; +import { FeatureFlagsController } from '../../../src/api/controllers/FeatureFlagController'; +import { SegmentController } from '../../../src/api/controllers/SegmentController'; +import { ErrorHandlerMiddleware } from '../../../src/api/middlewares/ErrorHandlerMiddleware'; +import { LogMiddleware } from '../../../src/api/middlewares/LogMiddleware'; +import { Experiment } from '../../../src/api/models/Experiment'; +import { ExperimentSegmentInclusion } from '../../../src/api/models/ExperimentSegmentInclusion'; +import { FeatureFlag } from '../../../src/api/models/FeatureFlag'; +import { FeatureFlagSegmentInclusion } from '../../../src/api/models/FeatureFlagSegmentInclusion'; +import { IndividualForSegment } from '../../../src/api/models/IndividualForSegment'; +import { MoocletExperimentRef } from '../../../src/api/models/MoocletExperimentRef'; +import { Segment } from '../../../src/api/models/Segment'; +import { User } from '../../../src/api/models/User'; +import { CacheService } from '../../../src/api/services/CacheService'; +import { ExperimentPrecomputedSegmentService } from '../../../src/api/services/ExperimentPrecomputedSegmentService'; +import { FeatureFlagPrecomputedSegmentService } from '../../../src/api/services/FeatureFlagPrecomputedSegmentService'; +import { MoocletExperimentService } from '../../../src/api/services/MoocletExperimentService'; +import { SegmentService } from '../../../src/api/services/SegmentService'; +import { BatchDeleteService } from '../../../src/api/services/batch/BatchDeleteService'; +import { DeletionEligibilityService } from '../../../src/api/services/batch/DeletionEligibilityService'; +import { currentUserChecker } from '../../../src/auth/currentUserChecker'; +import { env } from '../../../src/env'; +import { iocLoader } from '../../../src/loaders/iocLoader'; + +const entities: BatchDeleteEntity[] = ['experiments', 'flags', 'segments']; +const model = { experiments: Experiment, flags: FeatureFlag, segments: Segment }; +const route = (entity: BatchDeleteEntity) => `/api/${entity}/batch-delete`; + +export function registerBatchDeleteTests(connections: () => [DataSource, DataSource]) { + describe('Batch deletion', () => { + let db: DataSource; + let writer: DataSource; + let app: Application; + let originalAuth: boolean; + let originalMooclet: boolean; + beforeAll(() => { + iocLoader(); + const { authorizationChecker } = jest.requireActual('../../../src/auth/authorizationChecker'); + app = createExpressServer({ + routePrefix: '/api', + controllers: [ExperimentController, FeatureFlagsController, SegmentController], + middlewares: [LogMiddleware, ErrorHandlerMiddleware], + classTransformer: true, + validation: { whitelist: true, validationError: { target: false, value: false } }, + defaultErrorHandler: false, + authorizationChecker: authorizationChecker(), + currentUserChecker, + }); + }); + beforeEach(() => { + [db, writer] = connections(); + originalAuth = env.google.authTokenRequired; + originalMooclet = env.mooclets.enabled; + env.google.authTokenRequired = false; + env.mooclets.enabled = false; + }); + afterEach(() => { + env.google.authTokenRequired = originalAuth; + env.mooclets.enabled = originalMooclet; + jest.restoreAllMocks(); + }); + + const create = async (entity: BatchDeleteEntity, count = 1) => { + const rows = Array.from({ length: count }, (_, i) => ({ id: randomUUID(), name: `Batch ${i}`, context: 'home' })); + if (entity === 'experiments') { + await db.getRepository(Experiment).insert( + rows.map((row) => ({ + ...row, + context: [row.context], + description: '', + assignmentUnit: ASSIGNMENT_UNIT.INDIVIDUAL, + postExperimentRule: POST_EXPERIMENT_RULE.CONTINUE, + })) + ); + } else if (entity === 'flags') { + await db.getRepository(FeatureFlag).insert( + rows.map((row) => ({ + ...row, + context: [row.context], + key: row.id, + description: '', + })) + ); + } else await db.getRepository(Segment).insert(rows.map((row) => ({ ...row, type: SEGMENT_TYPE.PUBLIC }))); + return rows; + }; + const ownedList = async (entity: BatchDeleteEntity, ownerId: string, childId?: string, connection = db) => { + const list = { + id: randomUUID(), + name: 'Owned list', + context: 'home', + type: SEGMENT_TYPE.PRIVATE, + listType: childId ? STANDARD_LIST_TYPE.SEGMENT : STANDARD_LIST_TYPE.INDIVIDUAL, + }; + await connection.getRepository(Segment).insert(list); + await connection.getRepository(IndividualForSegment).insert({ segmentId: list.id, userId: 'batch-member' }); + if (childId) await connection.createQueryBuilder().relation(Segment, 'subSegments').of(list.id).add(childId); + if (entity === 'experiments') { + await connection + .getRepository(ExperimentSegmentInclusion) + .insert({ experimentId: ownerId, segmentId: list.id }); + } else if (entity === 'flags') { + await connection.getRepository(FeatureFlagSegmentInclusion).insert({ + featureFlagId: ownerId, + segmentId: list.id, + listType: list.listType, + enabled: false, + }); + } else await connection.createQueryBuilder().relation(Segment, 'subSegments').of(ownerId).add(list.id); + return list; + }; + + test.each(entities)('%s validates IDs and authentication before dispatch', async (entity) => { + const spy = jest.spyOn(Container.get(BatchDeleteService), 'delete'); + const id = randomUUID(); + for (const body of [{}, { ids: [] }, { ids: ['bad-id'] }, { ids: [id, id.toUpperCase()] }]) { + await request(app).post(route(entity)).send(body).expect(400); + } + env.google.authTokenRequired = true; + await request(app) + .post(route(entity)) + .send({ ids: [id] }) + .expect(401); + env.google.authTokenRequired = false; + await db.getRepository(User).delete({ email: SYSTEM_USER_EMAIL }); + await request(app) + .post(route(entity)) + .send({ ids: [id] }) + .expect(401); + expect(spy).not.toHaveBeenCalled(); + }); + + test.each(Object.values(UserRole))('enforces server-side %s permissions despite body overrides', async (role) => { + await db.getRepository(User).update(SYSTEM_USER_EMAIL, { role }); + for (const entity of entities) { + const [row] = await create(entity); + const allowed = + role === UserRole.ADMIN || + role === UserRole.CREATOR || + (role === UserRole.USER_MANAGER && entity === 'segments'); + const { body } = await request(app) + .post(route(entity)) + .send({ + ids: [row.id], + role: UserRole.ADMIN, + canDelete: true, + }) + .expect(allowed ? 200 : 403); + if (allowed) expect(body.results).toEqual([{ id: row.id, outcome: 'deleted' }]); + expect(await db.getRepository(model[entity]).countBy({ id: row.id })).toBe(allowed ? 0 : 1); + } + }); + + test.each(entities)('%s deletes owned lists and members while preserving public children', async (entity) => { + const rows = await create(entity, 2); + const [child] = await create('segments'); + const lists = await Promise.all(rows.map((row) => ownedList(entity, row.id, child.id))); + const ids = rows.map((row) => row.id.toUpperCase()).reverse(); + const { body } = await request(app).post(route(entity)).send({ ids }).expect(200); + expect(body).toEqual({ phase: 'executed', results: ids.map((id) => ({ id, outcome: 'deleted' })) }); + expect(await db.getRepository(model[entity]).countBy({ id: In(ids) })).toBe(0); + expect(await db.getRepository(Segment).countBy({ id: In(lists.map((list) => list.id)) })).toBe(0); + expect( + await db.getRepository(IndividualForSegment).countBy({ segmentId: In(lists.map((list) => list.id)) }) + ).toBe(0); + expect(await db.getRepository(Segment).countBy({ id: child.id })).toBe(1); + }); + + test.each(entities)('%s rejects the entire selection when an ID is missing', async (entity) => { + const [row] = await create(entity); + const missing = randomUUID(); + const { body } = await request(app) + .post(route(entity)) + .send({ ids: [row.id, missing] }) + .expect(200); + expect(body).toEqual({ + phase: 'rejected', + results: [ + { id: row.id, outcome: 'not_attempted' }, + { id: missing, outcome: 'not_found', reasonCode: DeletionReasonCode.NOT_FOUND }, + ], + }); + expect(await db.getRepository(model[entity]).countBy({ id: row.id })).toBe(1); + }); + + test('a hidden Used segment blocks deletion of otherwise eligible selections', async () => { + const [unused, child, parent] = await create('segments', 3); + await ownedList('segments', parent.id, child.id); + const [experiment] = await create('experiments'); + await ownedList('experiments', experiment.id, parent.id); + const { body } = await request(app) + .post(route('segments')) + .send({ + ids: [unused.id, child.id], + searchParams: { key: 'name', string: unused.name }, + }) + .expect(200); + expect(body.phase).toBe('rejected'); + expect(body.results).toEqual([ + { id: unused.id, outcome: 'not_attempted' }, + { id: child.id, outcome: 'ineligible', reasonCode: DeletionReasonCode.SEGMENT_IN_USE }, + ]); + expect(await db.getRepository(Segment).countBy({ id: In([unused.id, child.id]) })).toBe(2); + }); + + test.each(entities)('%s rechecks eligibility after preflight using the mutation transaction', async (entity) => { + const [row] = await create(entity); + const service = Container.get(DeletionEligibilityService); + const original = service[entity].bind(service); + jest.spyOn(service, entity).mockImplementationOnce(async (ids, user) => { + const result = await original(ids, user); + // A separate connection commits a normal edit after the bulk read, before deletion begins. + if (entity === 'experiments') + await writer.getRepository(Experiment).update(row.id, { state: EXPERIMENT_STATE.DRAFT }); + else if (entity === 'flags') + await writer.getRepository(FeatureFlag).update(row.id, { status: FEATURE_FLAG_STATUS.ENABLED }); + else { + const [flag] = await create('flags'); + await ownedList('flags', flag.id, row.id, writer); + } + return result; + }); + const { body } = await request(app) + .post(route(entity)) + .send({ ids: [row.id] }) + .expect(200); + expect(body).toEqual({ + phase: 'executed', + results: [ + { + id: row.id, + outcome: 'ineligible', + reasonCode: + entity === 'experiments' + ? DeletionReasonCode.EXPERIMENT_STATE_UNSUPPORTED + : entity === 'flags' + ? DeletionReasonCode.FEATURE_FLAG_ENABLED + : DeletionReasonCode.SEGMENT_IN_USE, + }, + ], + }); + expect(await db.getRepository(model[entity]).countBy({ id: row.id })).toBe(1); + }); + + test('reports an owned-list cleanup rollback after a committed success and stops the remaining items', async () => { + const rows = await create('flags', 3); + const list = await ownedList('flags', rows[1].id); + try { + await db.query(`CREATE OR REPLACE FUNCTION batch_test_reject_delete() RETURNS trigger AS $$ + BEGIN IF OLD.id = TG_ARGV[0]::uuid THEN RAISE EXCEPTION 'batch cleanup failure'; END IF; + RETURN OLD; END; $$ LANGUAGE plpgsql`); + await db.query('DROP TRIGGER IF EXISTS batch_test_reject_delete ON segment'); + // The interpolated UUID is generated only by this fixture. + await db.query(`CREATE TRIGGER batch_test_reject_delete BEFORE DELETE ON segment + FOR EACH ROW EXECUTE FUNCTION batch_test_reject_delete('${list.id}')`); + const { body } = await request(app) + .post(route('flags')) + .send({ ids: rows.map((row) => row.id) }) + .expect(200); + expect(body).toEqual({ + phase: 'executed', + results: [ + { id: rows[0].id, outcome: 'deleted' }, + { id: rows[1].id, outcome: 'failed', reasonCode: DeletionReasonCode.DELETE_FAILED }, + { id: rows[2].id, outcome: 'not_attempted' }, + ], + }); + expect(await db.getRepository(FeatureFlag).countBy({ id: rows[0].id })).toBe(0); + expect(await db.getRepository(FeatureFlag).countBy({ id: In(rows.slice(1).map((row) => row.id)) })).toBe(2); + expect(await db.getRepository(Segment).countBy({ id: list.id })).toBe(1); + expect(await db.getRepository(IndividualForSegment).countBy({ segmentId: list.id })).toBe(1); + expect(await db.getRepository(FeatureFlagSegmentInclusion).countBy({ segmentId: list.id })).toBe(1); + } finally { + await db.query('DROP TRIGGER IF EXISTS batch_test_reject_delete ON segment'); + await db.query('DROP FUNCTION IF EXISTS batch_test_reject_delete()'); + } + }); + + test('bounds a guard lock wait and reports a confirmed failure without deleting', async () => { + const [row] = await create('flags'); + const blocker = writer.createQueryRunner(); + await blocker.connect(); + await blocker.startTransaction(); + try { + await blocker.query('SELECT id FROM feature_flag WHERE id = $1 FOR UPDATE', [row.id]); + const original = db.createQueryRunner.bind(db); + jest.spyOn(db, 'createQueryRunner').mockImplementation((...args) => { + const runner = original(...args); + const start = runner.startTransaction.bind(runner); + jest.spyOn(runner, 'startTransaction').mockImplementation(async (...startArgs) => { + await start(...startArgs); + // A tighter deployment default must survive the batch's five-second cap. + await runner.query("SET LOCAL lock_timeout = '100ms'"); + }); + return runner; + }); + const { body } = await request(app) + .post(route('flags')) + .send({ ids: [row.id] }) + .expect(200); + expect(body.results).toEqual([{ id: row.id, outcome: 'failed', reasonCode: DeletionReasonCode.LOCK_TIMEOUT }]); + expect(await db.getRepository(FeatureFlag).countBy({ id: row.id })).toBe(1); + } finally { + await blocker.rollbackTransaction(); + await blocker.release(); + } + }); + + test('keeps a committed segment deletion when post-commit cache invalidation fails', async () => { + const rows = await create('segments', 2); + const cache = Container.get(CacheService); + const reset = cache.resetPrefixCache.bind(cache); + let observedCount: number; + jest.spyOn(cache, 'resetPrefixCache').mockImplementation(async (prefix) => { + if (prefix === CACHE_PREFIX.SEGMENT_KEY_PREFIX) { + observedCount = await writer.getRepository(Segment).countBy({ id: rows[0].id }); + throw new Error('Cache unavailable after commit'); + } + return reset(prefix); + }); + const flags = jest.spyOn(Container.get(FeatureFlagPrecomputedSegmentService), 'withRecompute'); + const experiments = jest.spyOn( + Container.get(ExperimentPrecomputedSegmentService), + 'scheduleRecomputeForExperiments' + ); + const { body } = await request(app) + .post(route('segments')) + .send({ ids: rows.map((row) => row.id) }) + .expect(200); + expect(body.results).toEqual([ + { id: rows[0].id, outcome: 'deleted', reasonCode: DeletionReasonCode.POST_DELETE_FAILED }, + { id: rows[1].id, outcome: 'not_attempted' }, + ]); + expect(observedCount).toBe(0); + expect(flags).toHaveBeenCalledTimes(1); + expect(experiments).toHaveBeenCalledTimes(1); + expect(await db.getRepository(Segment).countBy({ id: rows[1].id })).toBe(1); + }); + + test.each([true, false])( + 'preserves Mooclet orchestration and local commit/rollback when remote deletion returns %s', + async (success) => { + env.mooclets.enabled = true; + const [row] = await create('experiments'); + const list = await ownedList('experiments', row.id); + const service = Container.get(MoocletExperimentService); + const ref = { id: randomUUID() } as MoocletExperimentRef; + jest.spyOn(service, 'getMoocletExperimentRefByUpgradeExperimentId').mockResolvedValue(ref); + // Only the network boundary is stubbed; syncDelete and its nested local transaction run normally. + const remote = jest.spyOn(service, 'orchestrateDeleteMoocletResources').mockResolvedValue(success); + const { body } = await request(app) + .post(route('experiments')) + .send({ ids: [row.id] }) + .expect(200); + expect(body.results).toEqual([ + success + ? { id: row.id, outcome: 'deleted' } + : { + id: row.id, + outcome: 'failed', + reasonCode: DeletionReasonCode.EXTERNAL_SYNC_FAILED, + }, + ]); + expect(remote).toHaveBeenCalledWith(ref, expect.anything()); + expect(await db.getRepository(Experiment).countBy({ id: row.id })).toBe(success ? 0 : 1); + expect(await db.getRepository(Segment).countBy({ id: list.id })).toBe(success ? 0 : 1); + } + ); + + test.each(entities)('%s single-delete route keeps its existing policy and response shape', async (entity) => { + await db.getRepository(User).update(SYSTEM_USER_EMAIL, { role: UserRole.READER }); + const [row] = await create(entity); + if (entity === 'experiments') + await db.getRepository(Experiment).update(row.id, { state: EXPERIMENT_STATE.DRAFT }); + else if (entity === 'flags') + await db.getRepository(FeatureFlag).update(row.id, { status: FEATURE_FLAG_STATUS.ENABLED }); + else await db.getRepository(Segment).update(row.id, { type: SEGMENT_TYPE.PRIVATE }); + const { body } = await request(app).delete(`/api/${entity}/${row.id}`).expect(200); + expect(body).toEqual( + entity === 'segments' ? expect.objectContaining({ id: row.id }) : [expect.objectContaining({ id: row.id })] + ); + expect(await db.getRepository(model[entity]).countBy({ id: row.id })).toBe(0); + }); + + test.each(entities.flatMap((entity) => [20, 100, 500].map((count) => ({ entity, count }))))( + '$entity deletes $count selections without repeating global eligibility reads per item', + async ({ entity, count }) => { + const rows = await create(entity, count); + const ids = rows.map((row) => row.id).reverse(); + const status = jest.spyOn(Container.get(SegmentService), 'getSegmentStatus'); + const queries = jest.spyOn(db.logger, 'logQuery'); + const start = performance.now(); + const { body } = await request(app).post(route(entity)).send({ ids }).expect(200); + const durationMs = Math.round(performance.now() - start); + const queryCount = queries.mock.calls.length; + expect(body).toEqual({ phase: 'executed', results: ids.map((id) => ({ id, outcome: 'deleted' })) }); + expect(status).toHaveBeenCalledTimes(entity === 'segments' ? 1 : 0); + expect(await db.getRepository(model[entity]).countBy({ id: In(ids) })).toBe(0); + // Opt-in local measurement; keep CI output quiet and avoid machine-dependent timing assertions. + if (process.env.BATCH_DELETE_MEASURE === 'true') + console.info(JSON.stringify({ entity, count, durationMs, queryCount })); + } + ); + }); +} diff --git a/packages/backend/test/integration/index.test.ts b/packages/backend/test/integration/index.test.ts index 69db3ead0..0200dfa04 100644 --- a/packages/backend/test/integration/index.test.ts +++ b/packages/backend/test/integration/index.test.ts @@ -126,6 +126,7 @@ import { ListValueFiltering } from './ListValueFiltering'; import { FeatureFlagDeleteCleanup, SegmentDeleteCleanup } from './DeleteCleanup'; import { registerSegmentDeletionGuardTests } from './SegmentDeletionGuard'; import { registerDeletionEligibilityTests } from './DeletionEligibility'; +import { registerBatchDeleteTests } from './BatchDelete'; describe('Integration Tests', () => { jest.setTimeout(100000000); @@ -137,6 +138,7 @@ describe('Integration Tests', () => { let exportConnection: DataSource; registerSegmentDeletionGuardTests(() => [defaultConnection, exportConnection]); registerDeletionEligibilityTests(() => [defaultConnection, exportConnection]); + registerBatchDeleteTests(() => [defaultConnection, exportConnection]); beforeAll(async () => { configureLogger(); [defaultConnection, exportConnection] = await createDatabaseConnection(); diff --git a/packages/backend/test/unit/controllers/ExperimentController.test.ts b/packages/backend/test/unit/controllers/ExperimentController.test.ts index d65ec7ec5..fc5493b9e 100644 --- a/packages/backend/test/unit/controllers/ExperimentController.test.ts +++ b/packages/backend/test/unit/controllers/ExperimentController.test.ts @@ -1,4 +1,5 @@ import { DeletionEligibilityService } from '../../../src/api/services/batch/DeletionEligibilityService'; +import { BatchDeleteService } from '../../../src/api/services/batch/BatchDeleteService'; import app from '../../utils/expressApp'; import request from 'supertest'; import { configureLogger } from '../../utils/logger'; @@ -36,6 +37,7 @@ describe('Experiment Controller Testing', () => { routingUseContainer(Container); classValidatorUseContainer(Container); Container.set(DeletionEligibilityService, {} as DeletionEligibilityService); + Container.set(BatchDeleteService, {} as BatchDeleteService); // set mock container Container.set(ExperimentService, new ExperimentServiceMock()); diff --git a/packages/backend/test/unit/controllers/FeatureFlagController.test.ts b/packages/backend/test/unit/controllers/FeatureFlagController.test.ts index 6c462ee8b..d73c45761 100644 --- a/packages/backend/test/unit/controllers/FeatureFlagController.test.ts +++ b/packages/backend/test/unit/controllers/FeatureFlagController.test.ts @@ -1,4 +1,5 @@ import { DeletionEligibilityService } from '../../../src/api/services/batch/DeletionEligibilityService'; +import { BatchDeleteService } from '../../../src/api/services/batch/BatchDeleteService'; import app from '../../utils/expressApp'; import request from 'supertest'; import { configureLogger } from '../../utils/logger'; @@ -20,6 +21,7 @@ describe('Feature Flag Controller Testing', () => { routingUseContainer(Container); classValidatorUseContainer(Container); Container.set(DeletionEligibilityService, {} as DeletionEligibilityService); + Container.set(BatchDeleteService, {} as BatchDeleteService); Container.set(FeatureFlagService, new FeatureFlagServiceMock()); Container.set(ExperimentUserService, new ExperimentUserServiceMock()); diff --git a/packages/backend/test/unit/controllers/SegmentController.test.ts b/packages/backend/test/unit/controllers/SegmentController.test.ts index 36847d31e..c57a45ceb 100644 --- a/packages/backend/test/unit/controllers/SegmentController.test.ts +++ b/packages/backend/test/unit/controllers/SegmentController.test.ts @@ -1,4 +1,5 @@ import { DeletionEligibilityService } from '../../../src/api/services/batch/DeletionEligibilityService'; +import { BatchDeleteService } from '../../../src/api/services/batch/BatchDeleteService'; import app from '../../utils/expressApp'; import request from 'supertest'; import { configureLogger } from '../../utils/logger'; @@ -16,6 +17,7 @@ describe('Segment Controller Testing', () => { routingUseContainer(Container); classValidatorUseContainer(Container); Container.set(DeletionEligibilityService, {} as DeletionEligibilityService); + Container.set(BatchDeleteService, {} as BatchDeleteService); // set mock container Container.set(SegmentService, new SegmentServiceMock()); diff --git a/packages/backend/test/unit/services/batch/BatchDeleteService.test.ts b/packages/backend/test/unit/services/batch/BatchDeleteService.test.ts new file mode 100644 index 000000000..51706c307 --- /dev/null +++ b/packages/backend/test/unit/services/batch/BatchDeleteService.test.ts @@ -0,0 +1,350 @@ +import { randomUUID } from 'crypto'; +import { performance } from 'perf_hooks'; +import { DataSource, QueryRunner } from 'typeorm'; +import { BatchDeleteEntity, DeletionEligibilityResult, DeletionReasonCode, UserRole } from 'upgrade_types'; +import { BatchDeleteService } from '../../../../src/api/services/batch/BatchDeleteService'; +import { DeletionEligibilityService } from '../../../../src/api/services/batch/DeletionEligibilityService'; +import { ExperimentService } from '../../../../src/api/services/ExperimentService'; +import { FeatureFlagService } from '../../../../src/api/services/FeatureFlagService'; +import { SegmentService } from '../../../../src/api/services/SegmentService'; +import { MoocletExperimentService } from '../../../../src/api/services/MoocletExperimentService'; +import { MoocletError } from '../../../../src/api/errors/MoocletError'; +import { UpgradeLogger } from '../../../../src/lib/logger/UpgradeLogger'; +import { env } from '../../../../src/env'; +import * as segmentGuard from '../../../../src/api/services/batch/SegmentDeletionGuard'; + +jest.mock('perf_hooks', () => ({ performance: { now: jest.fn(() => 0) } })); + +describe('BatchDeleteService transaction outcomes', () => { + const entities: BatchDeleteEntity[] = ['experiments', 'flags', 'segments']; + const user = { email: 'batch@example.com', firstName: 'Batch', lastName: 'User', role: UserRole.ADMIN }; + const logger = { error: jest.fn() } as unknown as UpgradeLogger; + let ids: string[]; + let service: BatchDeleteService; + let runners: QueryRunner[]; + let mutations: string[]; + let eligibility: Record; + let experiments: { delete: jest.Mock }; + let flags: { delete: jest.Mock }; + let segments: { deleteSegment: jest.Mock }; + let mooclets: { getMoocletExperimentRefByUpgradeExperimentId: jest.Mock; syncDelete: jest.Mock }; + let createQueryRunner: jest.Mock; + let work: jest.Mock; + let originalMooclet: boolean; + let configureRunner: (runner: QueryRunner, index: number) => void; + + beforeEach(() => { + ids = [randomUUID(), randomUUID(), randomUUID()]; + mutations = []; + runners = []; + (performance.now as jest.Mock).mockReturnValue(0); + originalMooclet = env.mooclets.enabled; + env.mooclets.enabled = false; + jest.spyOn(segmentGuard, 'assertSegmentDeletionAllowed').mockResolvedValue(undefined); + const eligible = async (requested: string[]): Promise => ({ + allDeletable: true, + items: requested.map((id) => ({ id, availability: 'present', canDelete: true })), + }); + eligibility = { experiments: jest.fn(eligible), flags: jest.fn(eligible), segments: jest.fn(eligible) }; + configureRunner = () => undefined; + createQueryRunner = jest.fn(() => { + let active = false; + const runner = { + get isTransactionActive() { + return active; + }, + manager: { + query: jest.fn().mockResolvedValue([]), + getRepository: jest.fn(() => ({ + findOne: jest.fn(async ({ where }) => ({ id: where.id, state: 'inactive', status: 'disabled' })), + })), + }, + connect: jest.fn().mockResolvedValue(undefined), + startTransaction: jest.fn(async () => { + active = true; + }), + commitTransaction: jest.fn(async () => { + active = false; + }), + rollbackTransaction: jest.fn(async () => { + active = false; + }), + release: jest.fn().mockResolvedValue(undefined), + } as unknown as QueryRunner; + configureRunner(runner, runners.length); + runners.push(runner); + return runner; + }); + work = jest.fn(async (id) => { + mutations.push(id); + return [{ id }]; + }); + experiments = { delete: jest.fn((id, _user, options) => options.executeTransaction(() => work(id))) }; + flags = { delete: jest.fn((id, _user, _logger, transaction) => transaction(() => work(id))) }; + segments = { + deleteSegment: jest.fn((id, _logger, guard, transaction) => + transaction(async (manager) => { + await guard(manager); + const [row] = await work(id); + return row; + }) + ), + }; + mooclets = { + getMoocletExperimentRefByUpgradeExperimentId: jest.fn().mockResolvedValue(undefined), + syncDelete: jest.fn((params, transaction) => transaction(() => work(params.experimentId))), + }; + service = new BatchDeleteService( + { createQueryRunner } as unknown as DataSource, + eligibility as unknown as DeletionEligibilityService, + experiments as unknown as ExperimentService, + flags as unknown as FeatureFlagService, + segments as unknown as SegmentService, + mooclets as unknown as MoocletExperimentService + ); + }); + afterEach(() => { + env.mooclets.enabled = originalMooclet; + jest.restoreAllMocks(); + }); + + test.each(entities)('%s rejects unauthorized users before any preflight or mutation', async (entity) => { + await expect(service.delete(entity, ids, undefined, logger)).rejects.toMatchObject({ httpCode: 401 }); + for (const role of [UserRole.READER, undefined, 'unknown' as UserRole]) { + await expect(service.delete(entity, ids, { ...user, role }, logger)).rejects.toMatchObject({ httpCode: 403 }); + } + expect(eligibility[entity]).not.toHaveBeenCalled(); + expect(createQueryRunner).not.toHaveBeenCalled(); + }); + + test.each(entities)('%s commits sequentially and normalizes existing service responses', async (entity) => { + const result = await service.delete(entity, ids, user, logger); + expect(result).toEqual({ phase: 'executed', results: ids.map((id) => ({ id, outcome: 'deleted' })) }); + expect(eligibility[entity]).toHaveBeenCalledTimes(1); + expect(mutations).toEqual(ids); + for (const runner of runners) { + expect(runner.commitTransaction).toHaveBeenCalledTimes(1); + expect(runner.rollbackTransaction).not.toHaveBeenCalled(); + expect(runner.release).toHaveBeenCalledTimes(1); + } + }); + + test('rejects the whole selection when a hidden item cannot be deleted', async () => { + eligibility.segments.mockResolvedValue({ + allDeletable: false, + items: [ + { id: ids[0], canDelete: true, availability: 'present' }, + { id: ids[1], canDelete: false, availability: 'present', reasonCode: DeletionReasonCode.SEGMENT_IN_USE }, + { id: ids[2], canDelete: false, availability: 'not_found', reasonCode: DeletionReasonCode.NOT_FOUND }, + ], + }); + const result = await service.delete('segments', ids, user, logger); + expect(result.phase).toBe('rejected'); + expect(result.results.map((item) => item.outcome)).toEqual(['not_attempted', 'ineligible', 'not_found']); + expect(createQueryRunner).not.toHaveBeenCalled(); + expect(mutations).toEqual([]); + }); + + test('returns a no-mutation result when preflight cannot be completed', async () => { + eligibility.flags.mockRejectedValue(new Error('read failed')); + expect(await service.delete('flags', ids, user, logger)).toEqual({ + phase: 'rejected', + results: ids.map((id) => ({ + id, + outcome: 'not_attempted', + reasonCode: DeletionReasonCode.ELIGIBILITY_UNAVAILABLE, + })), + }); + expect(createQueryRunner).not.toHaveBeenCalled(); + }); + + test.each([0, 1])('stops after item %i fails and preserves earlier commits', async (index) => { + work.mockImplementation(async (id) => { + if (id === ids[index]) throw new Error('cleanup failed'); + mutations.push(id); + return [{ id }]; + }); + const result = await service.delete('flags', ids, user, logger); + expect(result.results.map((item) => item.outcome)).toEqual( + index === 0 ? ['failed', 'not_attempted', 'not_attempted'] : ['deleted', 'failed', 'not_attempted'] + ); + expect(runners[index].rollbackTransaction).toHaveBeenCalledTimes(1); + expect(runners[index].commitTransaction).not.toHaveBeenCalled(); + expect(mutations).toEqual(ids.slice(0, index)); + }); + + test.each(['missing', 'enabled'])('rejects a %s flag found by the locked mutation check', async (changed) => { + configureRunner = (runner) => { + (runner.manager.getRepository as jest.Mock).mockReturnValue({ + findOne: jest.fn().mockResolvedValue(changed === 'missing' ? null : { id: ids[0], status: 'enabled' }), + }); + }; + const result = await service.delete('flags', ids, user, logger); + expect(result.results[0].outcome).toBe(changed === 'missing' ? 'not_found' : 'ineligible'); + expect(mutations).toEqual([]); + }); + + test('reports a lock timeout only after a confirmed rollback', async () => { + work.mockRejectedValue(Object.assign(new Error('lock wait'), { code: '55P03' })); + const result = await service.delete('flags', ids, user, logger); + expect(result.results[0]).toEqual({ id: ids[0], outcome: 'failed', reasonCode: DeletionReasonCode.LOCK_TIMEOUT }); + expect(runners[0].rollbackTransaction).toHaveBeenCalled(); + }); + + test.each(['missing', 'protected', 'used'] as const)( + 'reports a segment becoming %s after preflight', + async (reason) => { + jest + .spyOn(segmentGuard, 'assertSegmentDeletionAllowed') + .mockRejectedValue(new segmentGuard.SegmentDeletionBlockedError(ids[0], reason)); + const result = await service.delete('segments', ids, user, logger); + expect(result.results[0]).toEqual({ + id: ids[0], + outcome: reason === 'missing' ? 'not_found' : 'ineligible', + reasonCode: + reason === 'missing' + ? DeletionReasonCode.NOT_FOUND + : reason === 'protected' + ? DeletionReasonCode.PROTECTED_SEGMENT_TYPE + : DeletionReasonCode.SEGMENT_IN_USE, + }); + expect(result.results.slice(1).every((item) => item.outcome === 'not_attempted')).toBe(true); + expect(mutations).toEqual([]); + expect(runners[0].rollbackTransaction).toHaveBeenCalled(); + } + ); + + test('does not claim rollback when COMMIT acknowledgment is lost, even if ROLLBACK succeeds', async () => { + configureRunner = (runner) => { + (runner.commitTransaction as jest.Mock).mockRejectedValue(new Error('connection lost')); + }; + const result = await service.delete('experiments', ids, user, logger); + expect(result.results[0]).toEqual({ + id: ids[0], + outcome: 'unknown', + reasonCode: DeletionReasonCode.OUTCOME_UNKNOWN, + }); + expect(result.results[1].outcome).toBe('not_attempted'); + expect(runners[0].rollbackTransaction).toHaveBeenCalled(); + }); + + test('reports unknown when mutation failed and rollback cannot be confirmed', async () => { + work.mockRejectedValue(new Error('write lost')); + configureRunner = (runner) => { + (runner.rollbackTransaction as jest.Mock).mockRejectedValue(new Error('rollback lost')); + }; + const result = await service.delete('flags', ids, user, logger); + expect(result.results[0].outcome).toBe('unknown'); + }); + + test('does not promote an empty legacy deletion response to a committed success', async () => { + work.mockResolvedValue([]); + const result = await service.delete('experiments', ids, user, logger); + expect(result.results[0].outcome).toBe('failed'); + expect(runners[0].commitTransaction).not.toHaveBeenCalled(); + expect(runners[0].rollbackTransaction).toHaveBeenCalled(); + }); + + test('keeps a known committed deletion when later cache cleanup fails and stops remaining items', async () => { + const original = segments.deleteSegment.getMockImplementation(); + segments.deleteSegment.mockImplementation(async (...args) => { + await original(...args); + throw new Error('cache failed'); + }); + const result = await service.delete('segments', ids, user, logger); + expect(result.results[0]).toEqual({ + id: ids[0], + outcome: 'deleted', + reasonCode: DeletionReasonCode.POST_DELETE_FAILED, + }); + expect(result.results[1].outcome).toBe('not_attempted'); + expect(runners[0].rollbackTransaction).not.toHaveBeenCalled(); + }); + + test('keeps a committed deletion when connection release fails', async () => { + configureRunner = (runner) => { + (runner.release as jest.Mock).mockRejectedValue(new Error('release failed')); + }; + expect((await service.delete('flags', ids, user, logger)).results[0]).toMatchObject({ + outcome: 'deleted', + reasonCode: DeletionReasonCode.POST_DELETE_FAILED, + }); + }); + + test('uses the existing Mooclet branch and reports external failure after local rollback', async () => { + env.mooclets.enabled = true; + const ref = { id: randomUUID() }; + mooclets.getMoocletExperimentRefByUpgradeExperimentId.mockResolvedValue(ref); + work.mockRejectedValue(new MoocletError('remote deletion failed')); + const result = await service.delete('experiments', ids, user, logger); + expect(experiments.delete).not.toHaveBeenCalled(); + expect(mooclets.syncDelete).toHaveBeenCalledWith( + expect.objectContaining({ moocletExperimentRef: ref }), + expect.any(Function) + ); + expect(result.results[0]).toEqual({ + id: ids[0], + outcome: 'failed', + reasonCode: DeletionReasonCode.EXTERNAL_SYNC_FAILED, + }); + expect(runners[0].rollbackTransaction).toHaveBeenCalled(); + }); + + test('uses ordinary experiment deletion when Mooclet is enabled but there is no reference', async () => { + env.mooclets.enabled = true; + await service.delete('experiments', ids, user, logger); + expect(experiments.delete).toHaveBeenCalledTimes(3); + expect(mooclets.syncDelete).not.toHaveBeenCalled(); + }); + + test('stops starting items after the budget, without abandoning a committed item', async () => { + work.mockImplementation(async (id) => { + (performance.now as jest.Mock).mockReturnValue(60_001); + return [{ id }]; + }); + const result = await service.delete('flags', ids, user, logger); + expect(result.results).toEqual([ + { id: ids[0], outcome: 'deleted' }, + ...ids + .slice(1) + .map((id) => ({ id, outcome: 'not_attempted', reasonCode: DeletionReasonCode.BATCH_BUDGET_EXCEEDED })), + ]); + expect(createQueryRunner).toHaveBeenCalledTimes(1); + }); + + test('does not start execution if the preflight consumes the budget', async () => { + const original = eligibility.flags.getMockImplementation(); + eligibility.flags.mockImplementation(async (...args) => { + const result = await original(...args); + (performance.now as jest.Mock).mockReturnValue(60_001); + return result; + }); + expect(await service.delete('flags', ids, user, logger)).toEqual({ + phase: 'rejected', + results: ids.map((id) => ({ + id, + outcome: 'not_attempted', + reasonCode: DeletionReasonCode.BATCH_BUDGET_EXCEEDED, + })), + }); + expect(createQueryRunner).not.toHaveBeenCalled(); + }); + + test('does not mutate when the budget expires while acquiring eligibility locks', async () => { + configureRunner = (runner) => { + (runner.manager.getRepository as jest.Mock).mockReturnValue({ + findOne: jest.fn(async () => { + (performance.now as jest.Mock).mockReturnValue(60_001); + return { id: ids[0], status: 'disabled' }; + }), + }); + }; + const result = await service.delete('flags', ids, user, logger); + expect(result.results[0]).toMatchObject({ + outcome: 'not_attempted', + reasonCode: DeletionReasonCode.BATCH_BUDGET_EXCEEDED, + }); + expect(mutations).toEqual([]); + expect(runners[0].rollbackTransaction).toHaveBeenCalled(); + }); +}); diff --git a/packages/types/src/BatchActions/index.ts b/packages/types/src/BatchActions/index.ts index e882bfeca..d6e7a7c69 100644 --- a/packages/types/src/BatchActions/index.ts +++ b/packages/types/src/BatchActions/index.ts @@ -1,4 +1,11 @@ -import { EXPERIMENT_STATE, FEATURE_FLAG_STATUS, SEGMENT_STATUS, SEGMENT_TYPE } from '../Experiment/enums'; +import { + EXPERIMENT_STATE, + EXPERIMENT_STATE_DISPLAY_NAME_OVERRIDES, + FEATURE_FLAG_STATUS, + SEGMENT_STATUS, + SEGMENT_TYPE, + UserRole, +} from '../Experiment/enums'; export interface BatchEntityIdsRequest { ids: string[]; @@ -17,8 +24,12 @@ export enum DeletionReasonCode { LOCK_TIMEOUT = 'lock_timeout', EXTERNAL_SYNC_FAILED = 'external_sync_failed', OUTCOME_UNKNOWN = 'outcome_unknown', + POST_DELETE_FAILED = 'post_delete_failed', + BATCH_BUDGET_EXCEEDED = 'batch_budget_exceeded', } +export type BatchDeleteEntity = 'experiments' | 'flags' | 'segments'; + export interface DeletionEligibilityItem { id: string; availability: 'present' | 'not_found' | 'unavailable'; @@ -55,3 +66,33 @@ export interface BatchDeleteResult { phase: 'rejected' | 'executed'; results: BatchDeleteItemResult[]; } + +export function hasBatchDeletePermission(role: UserRole, entity: BatchDeleteEntity): boolean { + return ( + role === UserRole.ADMIN || role === UserRole.CREATOR || (entity === 'segments' && role === UserRole.USER_MANAGER) + ); +} + +export function getExperimentDeletionState(state: EXPERIMENT_STATE): EXPERIMENT_STATE { + return EXPERIMENT_STATE_DISPLAY_NAME_OVERRIDES[state] || state; +} + +export function getExperimentDeletionReason(state: EXPERIMENT_STATE): DeletionReasonCode | undefined { + return [ + EXPERIMENT_STATE.INACTIVE, + EXPERIMENT_STATE.RUNNING, + EXPERIMENT_STATE.PAUSED, + EXPERIMENT_STATE.COMPLETED, + EXPERIMENT_STATE.ARCHIVED, + ].includes(getExperimentDeletionState(state)) + ? undefined + : DeletionReasonCode.EXPERIMENT_STATE_UNSUPPORTED; +} + +export function getFlagDeletionReason(status: FEATURE_FLAG_STATUS): DeletionReasonCode | undefined { + return status === FEATURE_FLAG_STATUS.ENABLED + ? DeletionReasonCode.FEATURE_FLAG_ENABLED + : [FEATURE_FLAG_STATUS.DISABLED, FEATURE_FLAG_STATUS.ARCHIVED].includes(status) + ? undefined + : DeletionReasonCode.FEATURE_FLAG_STATUS_UNSUPPORTED; +} diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index c5635d0cf..4eae613ba 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -96,6 +96,11 @@ export { export { SYSTEM_USER_EMAIL, DEV_USER_EMAIL, FAKE_DEV_CREDENTIAL } from './User'; export { BatchEntityIdsRequest, + BatchDeleteEntity, + hasBatchDeletePermission, + getExperimentDeletionState, + getExperimentDeletionReason, + getFlagDeletionReason, DeletionReasonCode, DeletionEligibilityItem, DeletionEligibilityResult, From c48a328a2f7e74a6312fd80807e5bc49ca47ab52 Mon Sep 17 00:00:00 2001 From: Zack Lee Date: Thu, 10 Sep 2026 14:51:13 -0400 Subject: [PATCH 04/61] feat: add frontend batch selection and deletion state flows --- .../src/app/core/api-endpoints.constants.ts | 6 + .../batch-actions/batch-actions.actions.ts | 42 ++ .../batch-actions/batch-actions.effects.ts | 224 +++++++++ .../batch-actions/batch-actions.facade.ts | 38 ++ .../batch-actions.helpers.spec.ts | 143 ++++++ .../batch-actions/batch-actions.helpers.ts | 123 +++++ .../batch-actions/batch-actions.http.spec.ts | 97 ++++ .../core/batch-actions/batch-actions.http.ts | 6 + .../batch-actions.integration.spec.ts | 454 ++++++++++++++++++ .../batch-actions.invalidation.ts | 47 ++ .../batch-actions/batch-actions.models.ts | 62 +++ .../batch-actions/batch-actions.reducer.ts | 272 +++++++++++ .../core/batch-actions/batch-actions.store.ts | 86 ++++ .../experiments/experiments.data.service.ts | 22 +- .../core/experiments/experiments.service.ts | 5 + .../experiments/store/experiments.actions.ts | 10 +- .../store/experiments.effects.spec.ts | 161 ++----- .../experiments/store/experiments.effects.ts | 196 +++++--- .../experiments/store/experiments.model.ts | 2 + .../store/experiments.reducer.spec.ts | 3 + .../experiments/store/experiments.reducer.ts | 26 +- .../store/experiments.selectors.ts | 4 + .../feature-flags.data.service.ts | 23 +- .../feature-flags/feature-flags.service.ts | 5 + .../store/feature-flags.actions.ts | 7 +- .../store/feature-flags.effects.ts | 182 +++++-- .../store/feature-flags.model.ts | 2 + .../store/feature-flags.reducer.ts | 32 +- .../store/feature-flags.selectors.ts | 4 + .../http-interceptors/http-context-tokens.ts | 3 + .../http-error.interceptor.ts | 7 +- .../local-storage.service.spec.ts | 3 + .../local-storage/local-storage.service.ts | 4 + .../core/segments/segments.data.service.ts | 24 +- .../src/app/core/segments/segments.service.ts | 5 + .../core/segments/store/segments.actions.ts | 9 +- .../core/segments/store/segments.effects.ts | 201 +++++--- .../app/core/segments/store/segments.model.ts | 2 + .../core/segments/store/segments.reducer.ts | 26 +- .../core/segments/store/segments.selectors.ts | 4 + .../projects/upgrade/src/assets/i18n/en.json | 12 + .../src/environments/environment-types.ts | 6 + 42 files changed, 2290 insertions(+), 300 deletions(-) create mode 100644 packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.actions.ts create mode 100644 packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.effects.ts create mode 100644 packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.facade.ts create mode 100644 packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.helpers.spec.ts create mode 100644 packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.helpers.ts create mode 100644 packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.http.spec.ts create mode 100644 packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.http.ts create mode 100644 packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.integration.spec.ts create mode 100644 packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.invalidation.ts create mode 100644 packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.models.ts create mode 100644 packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.reducer.ts create mode 100644 packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.store.ts diff --git a/packages/frontend/projects/upgrade/src/app/core/api-endpoints.constants.ts b/packages/frontend/projects/upgrade/src/app/core/api-endpoints.constants.ts index 06113fe0d..c26351c95 100644 --- a/packages/frontend/projects/upgrade/src/app/core/api-endpoints.constants.ts +++ b/packages/frontend/projects/upgrade/src/app/core/api-endpoints.constants.ts @@ -5,6 +5,12 @@ import { APIEndpoints } from '../../environments/environment-types'; * These are relative paths that will be prepended with the environment's apiBaseUrl by the HTTP interceptor. */ export const API_ENDPOINTS: APIEndpoints = { + experimentsDeletionEligibility: '/experiments/deletion-eligibility', + experimentsBatchDelete: '/experiments/batch-delete', + flagsDeletionEligibility: '/flags/deletion-eligibility', + flagsBatchDelete: '/flags/batch-delete', + segmentsDeletionEligibility: '/segments/deletion-eligibility', + segmentsBatchDelete: '/segments/batch-delete', getAllExperiments: '/experiments/paginated', createNewExperiments: '/experiments', validateExperiment: '/experiments/validation', diff --git a/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.actions.ts b/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.actions.ts new file mode 100644 index 000000000..9e5e37bad --- /dev/null +++ b/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.actions.ts @@ -0,0 +1,42 @@ +import { createAction, props } from '@ngrx/store'; +import { BatchDeleteResult, DeletionEligibilityResult } from 'upgrade_types'; +import { BatchDeleteSnapshot, RootSelectionItem } from './batch-actions.models'; + +export function createBatchActions(source: string) { + const prefix = `[${source} Batch]`; + return { + toggleRow: createAction(`${prefix} Toggle Row`, props<{ item: RootSelectionItem }>()), + toggleHeader: createAction(`${prefix} Toggle Header`, props<{ items: RootSelectionItem[] }>()), + clearSelection: createAction(`${prefix} Clear Selection`), + confirmedRemoved: createAction(`${prefix} Confirmed Removed`, props<{ ids: string[] }>()), + invalidateEligibility: createAction(`${prefix} Invalidate Eligibility`), + refreshEligibility: createAction( + `${prefix} Refresh Eligibility`, + props<{ requestId: string; forConfirmation: boolean }>() + ), + eligibilitySucceeded: createAction( + `${prefix} Eligibility Succeeded`, + props<{ requestId: string; revision: number; result: DeletionEligibilityResult }>() + ), + eligibilityFailed: createAction(`${prefix} Eligibility Failed`, props<{ requestId: string; revision: number }>()), + dismissConfirmation: createAction(`${prefix} Dismiss Confirmation`), + batchDeleteRequested: createAction(`${prefix} Delete Requested`, props<{ snapshot: BatchDeleteSnapshot }>()), + batchDeleteCompleted: createAction( + `${prefix} Delete Completed`, + props<{ operationId: string; result: BatchDeleteResult }>() + ), + batchDeleteRequestFailed: createAction( + `${prefix} Delete Request Failed`, + props<{ operationId: string; status: number }>() + ), + reconciliationCompleted: createAction( + `${prefix} Reconciliation Completed`, + props<{ operationId: string; result: DeletionEligibilityResult | null }>() + ), + listRequested: createAction(`${prefix} List Requested`, props<{ requestId: string; fromStarting: boolean }>()), + listFailed: createAction(`${prefix} List Failed`, props<{ requestId: string; batchRefresh: boolean }>()), + }; +} + +export type RootBatchActions = ReturnType; +export type RootBatchAction = ReturnType; diff --git a/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.effects.ts b/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.effects.ts new file mode 100644 index 000000000..9a98e0829 --- /dev/null +++ b/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.effects.ts @@ -0,0 +1,224 @@ +import { Action } from '@ngrx/store'; +import { EMPTY, Observable, concat, defer, fromEvent, merge, of } from 'rxjs'; +import { + auditTime, + catchError, + distinctUntilChanged, + exhaustMap, + filter, + map, + skipWhile, + switchMap, + takeUntil, + throwIfEmpty, + withLatestFrom, +} from 'rxjs/operators'; +import { BatchDeleteResult, DeletionEligibilityResult } from 'upgrade_types'; +import { RootBatchActions } from './batch-actions.actions'; +import { RootBatchState, isBatchBusy, newBatchRequestId } from './batch-actions.models'; +import { validateBatchResponse, validateEligibilityResponse } from './batch-actions.helpers'; + +export interface BatchDataSource { + checkDeletionEligibility(ids: string[]): Observable; + batchDelete(ids: string[]): Observable; +} + +export function eligibilityEffect( + events: Observable, + state$: Observable, + actions: RootBatchActions, + data: BatchDataSource +) { + return events.pipe( + filter((action) => action.type === actions.refreshEligibility.type), + withLatestFrom(state$), + filter( + ([action, state]) => + (action as ReturnType).requestId === state.eligibility.requestId + ), + switchMap(([, state]) => { + const { requestId, revision } = state.eligibility; + const ids = Object.keys(state.selectedById); + return defer(() => data.checkDeletionEligibility(ids)).pipe( + throwIfEmpty(), + map((result) => + actions.eligibilitySucceeded({ requestId, revision, result: validateEligibilityResponse(result, ids) }) + ), + catchError(() => of(actions.eligibilityFailed({ requestId, revision }))), + takeUntil(state$.pipe(filter((current) => current.eligibility.requestId !== requestId))) + ); + }) + ); +} + +export function batchDeleteEffect( + events: Observable, + state$: Observable, + actions: RootBatchActions, + data: BatchDataSource +) { + return events.pipe( + filter((action) => action.type === actions.batchDeleteRequested.type), + withLatestFrom(state$), + filter( + ([action, state]) => + state.operation?.status === 'submitting' && + state.operation.snapshot.operationId === + (action as ReturnType).snapshot.operationId + ), + exhaustMap(([, state]) => { + const { snapshot } = state.operation; + const ids = snapshot.items.map((item) => item.id); + return defer(() => data.batchDelete(ids)).pipe( + throwIfEmpty(), + map((result) => + actions.batchDeleteCompleted({ + operationId: snapshot.operationId, + result: validateBatchResponse(result, ids), + }) + ), + catchError((error) => + of(actions.batchDeleteRequestFailed({ operationId: snapshot.operationId, status: error?.status || 0 })) + ), + // Logout/user replacement discards session state. Navigation alone never cancels this request. + takeUntil(state$.pipe(filter((current) => current.userEmail !== state.userEmail || !current.operation))) + ); + }) + ); +} + +export function reconcileBatchEffect( + events: Observable, + state$: Observable, + actions: RootBatchActions, + data: BatchDataSource +) { + return events.pipe( + filter((action) => + [actions.batchDeleteCompleted.type, actions.batchDeleteRequestFailed.type].some((type) => type === action.type) + ), + withLatestFrom(state$), + filter( + ([action, state]) => + state.operation?.status === 'reconciling' && + state.operation.snapshot.operationId === (action as ReturnType).operationId + ), + exhaustMap(([, state]) => { + const operationId = state.operation.snapshot.operationId; + const ids = state.operation.snapshot.items.map((item) => item.id); + return defer(() => data.checkDeletionEligibility(ids)).pipe( + throwIfEmpty(), + map((result) => + actions.reconciliationCompleted({ operationId, result: validateEligibilityResponse(result, ids) }) + ), + catchError(() => of(actions.reconciliationCompleted({ operationId, result: null }))), + takeUntil(state$.pipe(filter((current) => current.userEmail !== state.userEmail || !current.operation))) + ); + }) + ); +} + +export function batchFinishedEffect( + events: Observable, + state$: Observable, + actions: RootBatchActions, + finish: (state: RootBatchState) => Action[] +) { + return events.pipe( + filter((action) => + [ + actions.batchDeleteCompleted.type, + actions.batchDeleteRequestFailed.type, + actions.reconciliationCompleted.type, + ].some((type) => type === action.type) + ), + withLatestFrom(state$), + filter( + ([action, state]) => + state.operation?.status === 'complete' && + state.operation.snapshot.operationId === (action as ReturnType).operationId + ), + distinctUntilChanged( + (previous, current) => previous[1].operation.snapshot.operationId === current[1].operation.snapshot.operationId + ), + switchMap(([, state]) => finish(state)) + ); +} + +/** Invalidation changes the revision before the read is dispatched. Coalescing is enforced in the reducer. */ +export function refreshSelectedEffect( + events: Observable, + actions: RootBatchActions, + invalidatingTypes: string[] +) { + return events.pipe( + filter((action) => invalidatingTypes.includes(action.type)), + switchMap(() => [ + actions.invalidateEligibility(), + actions.refreshEligibility({ requestId: newBatchRequestId(), forConfirmation: false }), + ]) + ); +} + +export function selectionFocusEffect(state$: Observable, actions: RootBatchActions) { + return defer(() => + typeof window === 'undefined' + ? EMPTY + : merge( + fromEvent(window, 'focus'), + fromEvent(document, 'visibilitychange').pipe(filter(() => document.visibilityState === 'visible')) + ) + ).pipe( + auditTime(100), + withLatestFrom(state$), + filter(([, state]) => !!Object.keys(state.selectedById).length && !isBatchBusy(state)), + map(() => actions.refreshEligibility({ requestId: newBatchRequestId(), forConfirmation: false })) + ); +} + +export function eligibilityAbsenceEffect( + events: Observable, + state$: Observable, + actions: RootBatchActions, + finish: (count: number) => Action[] +) { + return events.pipe( + filter((action) => action.type === actions.eligibilitySucceeded.type), + withLatestFrom(state$), + filter( + ([action, state]) => + (action as ReturnType).requestId === state.eligibility.requestId && + !!state.eligibility.absentIds?.length + ), + switchMap(([, state]) => finish(state.eligibility.absentIds.length)) + ); +} + +/** Read current state at subscription time, after the list-start action reaches the reducer. */ +export function trackedListRequest( + state$: Observable, + actions: RootBatchActions, + dispatch: (action: Action) => void, + fromStarting: boolean, + batchRefresh: boolean, + request: () => Observable, + success: (data: T, requestId: string) => Action[], + failure: () => Action[] +) { + return defer(() => { + const requestId = newBatchRequestId(); + dispatch(actions.listRequested({ requestId, fromStarting })); + return request().pipe( + switchMap((data) => success(data, requestId)), + catchError(() => concat(of(actions.listFailed({ requestId, batchRefresh })), of(...failure()))), + // NgRx queues a nested dispatch until the current action finishes. Observe our start before + // treating a different token (including null on deletion/logout) as cancellation. + takeUntil( + state$.pipe( + skipWhile((state) => state.listRequestId !== requestId), + filter((state) => state.listRequestId !== requestId) + ) + ) + ); + }); +} diff --git a/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.facade.ts b/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.facade.ts new file mode 100644 index 000000000..a0e8016cf --- /dev/null +++ b/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.facade.ts @@ -0,0 +1,38 @@ +import { Store, select } from '@ngrx/store'; +import { map, take } from 'rxjs/operators'; +import { BatchDeleteEntity } from 'upgrade_types'; +import { RootBatchActions } from './batch-actions.actions'; +import { RootBatchState, newBatchRequestId } from './batch-actions.models'; +import { selectionItem, selectionView } from './batch-actions.helpers'; + +/** Entity facades expose this same interface to root tables and the shared confirmation dialog. */ +export function createBatchFacade( + store: Store, + entity: BatchDeleteEntity, + actions: RootBatchActions, + selectState: (state: any) => RootBatchState, + selectRows: (state: any) => { id?: string; name?: string }[] +) { + const state$ = store.pipe(select(selectState)); + return { + state$, + selection$: state$.pipe(map((state) => selectionView(state, entity))), + toggleRow: (row: Parameters[0]) => + store.dispatch(actions.toggleRow({ item: selectionItem(row) })), + toggleHeader: () => + store + .pipe(select(selectRows), take(1)) + .subscribe((rows) => store.dispatch(actions.toggleHeader({ items: rows.map(selectionItem) }))), + clear: () => store.dispatch(actions.clearSelection()), + refresh: () => + store.dispatch(actions.refreshEligibility({ requestId: newBatchRequestId(), forConfirmation: false })), + prepareConfirmation: () => + store.dispatch(actions.refreshEligibility({ requestId: newBatchRequestId(), forConfirmation: true })), + dismissConfirmation: () => store.dispatch(actions.dismissConfirmation()), + submit: (operationId: string) => + state$.pipe(take(1)).subscribe((state) => { + if (state.confirmation?.operationId === operationId) + store.dispatch(actions.batchDeleteRequested({ snapshot: state.confirmation })); + }), + }; +} diff --git a/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.helpers.spec.ts b/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.helpers.spec.ts new file mode 100644 index 000000000..d732b7241 --- /dev/null +++ b/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.helpers.spec.ts @@ -0,0 +1,143 @@ +import { + BatchDeleteEntity, + DeletionReasonCode, + EXPERIMENT_STATE, + FEATURE_FLAG_STATUS, + SEGMENT_STATUS, + SEGMENT_TYPE, + UserRole, +} from 'upgrade_types'; +import { createBatchActions } from './batch-actions.actions'; +import { + localDeletionReason, + selectionView, + validateBatchResponse, + validateEligibilityResponse, +} from './batch-actions.helpers'; +import { RootBatchState, RootSelectionItem, initialRootBatchState } from './batch-actions.models'; +import { reduceRootBatch } from './batch-actions.reducer'; + +describe('Root selection rules', () => { + const actions = createBatchActions('Test'); + const item = (id: string): RootSelectionItem => ({ + id, + name: id, + stateOrStatus: SEGMENT_STATUS.UNUSED, + segmentType: SEGMENT_TYPE.PUBLIC, + }); + const state = (selected: string[], loaded: string[]): RootBatchState => ({ + ...initialRootBatchState, + role: UserRole.ADMIN, + selectedById: Object.fromEntries(selected.map((id) => [id, item(id)])), + loadedIds: loaded, + }); + + it.each([ + [[], [], false, false, 0], + [['a'], ['a', 'b'], false, true, 0], + [['a', 'b'], ['a', 'b'], true, false, 0], + [['a', 'b', 'hidden'], ['a', 'b'], true, false, 1], + [['a', 'hidden'], [], false, true, 2], + ])( + 'derives the header from selected=%j and loaded=%j', + (selected: string[], loaded: string[], checked: boolean, indeterminate: boolean, notShownCount: number) => { + expect(selectionView(state(selected, loaded), 'segments')).toMatchObject({ + checked, + indeterminate, + notShownCount, + }); + } + ); + + it('excludes global/private segments and detail-only IDs from header selection', () => { + const current = state([], ['public', 'private', 'global']); + const next = reduceRootBatch( + current, + actions.toggleHeader({ + items: [ + item('public'), + { ...item('private'), segmentType: SEGMENT_TYPE.PRIVATE }, + { ...item('global'), segmentType: SEGMENT_TYPE.GLOBAL_EXCLUDE }, + item('detail-only'), + ], + }), + actions, + 'segments' + ); + expect(Object.keys(next.selectedById)).toEqual(['public']); + expect(current.selectedById).toEqual({}); + }); + + it('can clear hidden selections during replacement loading but cannot select an obsolete page', () => { + const current = { ...state(['hidden'], []), listLoading: true }; + const cleared = reduceRootBatch(current, actions.toggleHeader({ items: [item('old')] }), actions, 'segments'); + expect(cleared.selectedById).toEqual({}); + expect( + reduceRootBatch(cleared, actions.toggleHeader({ items: [item('old')] }), actions, 'segments').selectedById + ).toEqual({}); + }); + + it.each([ + EXPERIMENT_STATE.INACTIVE, + EXPERIMENT_STATE.ENROLLING, + EXPERIMENT_STATE.ENROLLMENT_COMPLETE, + EXPERIMENT_STATE.CANCELLED, + EXPERIMENT_STATE.ARCHIVED, + ])('allows the existing experiment menu state %s', (status) => { + expect(localDeletionReason('experiments', { id: 'a', stateOrStatus: status }, UserRole.CREATOR)).toBeUndefined(); + }); + + it.each([ + [ + 'experiments', + { id: 'a', stateOrStatus: EXPERIMENT_STATE.DRAFT }, + UserRole.ADMIN, + DeletionReasonCode.EXPERIMENT_STATE_UNSUPPORTED, + ], + [ + 'flags', + { id: 'a', stateOrStatus: FEATURE_FLAG_STATUS.ENABLED }, + UserRole.ADMIN, + DeletionReasonCode.FEATURE_FLAG_ENABLED, + ], + ['flags', { id: 'a' }, UserRole.ADMIN, DeletionReasonCode.FEATURE_FLAG_STATUS_UNSUPPORTED], + [ + 'segments', + { ...item('a'), stateOrStatus: SEGMENT_STATUS.USED }, + UserRole.ADMIN, + DeletionReasonCode.SEGMENT_IN_USE, + ], + ['segments', item('a'), UserRole.READER, DeletionReasonCode.MISSING_PERMISSION], + ['segments', item('a'), UserRole.USER_MANAGER, undefined], + [ + 'flags', + { id: 'a', stateOrStatus: FEATURE_FLAG_STATUS.DISABLED }, + UserRole.USER_MANAGER, + DeletionReasonCode.MISSING_PERMISSION, + ], + ])( + 'applies entity-specific permissions and status rules for %s', + (entity: BatchDeleteEntity, selected: RootSelectionItem, role: UserRole, reason: DeletionReasonCode) => { + expect(localDeletionReason(entity, selected, role)).toBe(reason); + } + ); + + it('treats an incomplete or duplicate eligibility/deletion response as uncertain rather than silently removing rows', () => { + expect(() => validateEligibilityResponse({ items: [], allDeletable: true }, ['a'])).toThrow(); + expect(() => + validateBatchResponse({ phase: 'executed', results: [{ id: 'a', outcome: 'deleted' }] }, ['a', 'b']) + ).toThrow(); + expect(() => + validateBatchResponse( + { + phase: 'executed', + results: [ + { id: 'a', outcome: 'deleted' }, + { id: 'a', outcome: 'deleted' }, + ], + }, + ['a', 'b'] + ) + ).toThrow(); + }); +}); diff --git a/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.helpers.ts b/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.helpers.ts new file mode 100644 index 000000000..568206424 --- /dev/null +++ b/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.helpers.ts @@ -0,0 +1,123 @@ +import { + BatchDeleteEntity, + BatchDeleteResult, + DeletionEligibilityResult, + DeletionReasonCode, + EXPERIMENT_STATE, + FEATURE_FLAG_STATUS, + SEGMENT_STATUS, + SEGMENT_TYPE, + getExperimentDeletionReason, + getFlagDeletionReason, + hasBatchDeletePermission, +} from 'upgrade_types'; +import { RootBatchState, RootSelectionItem, isBatchBusy } from './batch-actions.models'; + +export function selectionItem(row: { + id?: string; + name?: string; + state?: EXPERIMENT_STATE; + status?: FEATURE_FLAG_STATUS | SEGMENT_STATUS; + type?: SEGMENT_TYPE; +}): RootSelectionItem { + return { id: row.id, name: row.name, stateOrStatus: row.state || row.status, segmentType: row.type }; +} + +export function localDeletionReason( + entity: BatchDeleteEntity, + item: RootSelectionItem, + role: RootBatchState['role'] +): DeletionReasonCode | undefined { + if (!hasBatchDeletePermission(role, entity)) return DeletionReasonCode.MISSING_PERMISSION; + if (item.availability === 'not_found') return DeletionReasonCode.NOT_FOUND; + if (item.availability === 'unavailable') return item.reasonCode || DeletionReasonCode.ELIGIBILITY_UNAVAILABLE; + if (item.reasonCode) return item.reasonCode; + if (entity === 'experiments') return getExperimentDeletionReason(item.stateOrStatus as EXPERIMENT_STATE); + if (entity === 'flags') return getFlagDeletionReason(item.stateOrStatus as FEATURE_FLAG_STATUS); + if (item.segmentType !== SEGMENT_TYPE.PUBLIC) return DeletionReasonCode.PROTECTED_SEGMENT_TYPE; + return item.stateOrStatus === SEGMENT_STATUS.UNUSED + ? undefined + : item.stateOrStatus === SEGMENT_STATUS.USED + ? DeletionReasonCode.SEGMENT_IN_USE + : DeletionReasonCode.ELIGIBILITY_UNAVAILABLE; +} + +export function selectionView(state: RootBatchState, entity: BatchDeleteEntity) { + const items = Object.values(state.selectedById); + const loaded = new Set(state.loadedIds); + const checked = loaded.size > 0 && [...loaded].every((id) => !!state.selectedById[id]); + const reasons = items.map((item) => localDeletionReason(entity, item, state.role)).filter(Boolean); + return { + items, + selectedCount: items.length, + checked, + indeterminate: items.length > 0 && !checked, + notShownCount: items.filter((item) => !loaded.has(item.id)).length, + canToggleHeader: !isBatchBusy(state) && (items.length > 0 || (!state.listLoading && loaded.size > 0)), + canRequestConfirmation: items.length > 0 && !reasons.length && !isBatchBusy(state), + reasonCode: reasons[0], + checking: state.eligibility.status === 'checking', + busy: isBatchBusy(state), + }; +} + +export const confirmedRemovedIds = (result?: BatchDeleteResult) => + result?.results.filter((item) => item.outcome === 'deleted' || item.outcome === 'not_found').map((item) => item.id) || + []; + +export function validateEligibilityResponse( + result: DeletionEligibilityResult, + ids: string[] +): DeletionEligibilityResult { + if ( + !result || + !Array.isArray(result.items) || + result.items.length !== ids.length || + new Set(result.items.map((item) => item.id)).size !== ids.length || + ids.some((id) => !result.items.some((item) => item.id === id)) || + result.items.some( + (item) => + !['present', 'not_found', 'unavailable'].includes(item.availability) || typeof item.canDelete !== 'boolean' + ) + ) { + throw new Error('Incomplete deletion eligibility response'); + } + return { + ...result, + allDeletable: + result.items.length > 0 && result.items.every((item) => item.availability === 'present' && item.canDelete), + }; +} + +export function validateBatchResponse(result: BatchDeleteResult, ids: string[]): BatchDeleteResult { + const outcomes = ['deleted', 'not_found', 'ineligible', 'forbidden', 'failed', 'unknown', 'not_attempted']; + if ( + !result || + !['rejected', 'executed'].includes(result.phase) || + !Array.isArray(result.results) || + result.results.length !== ids.length || + new Set(result.results.map((item) => item.id)).size !== ids.length || + ids.some((id) => !result.results.some((item) => item.id === id)) || + result.results.some((item) => !outcomes.includes(item.outcome)) + ) { + throw new Error('Incomplete batch deletion response'); + } + return result; +} + +export function batchResultCounts(state: RootBatchState) { + const results = state.operation?.result?.results || []; + const absent = new Set([ + ...results.filter((item) => item.outcome === 'not_found').map((item) => item.id), + ...(state.operation?.reconciledAbsentIds || []), + ]); + return { + deleted: results.filter((item) => item.outcome === 'deleted').length, + absent: absent.size, + remaining: Object.keys(state.selectedById).length, + uncertain: results.some((item) => item.outcome === 'unknown') || !!state.operation?.reconciliationFailed, + failed: results.filter((item) => ['failed', 'ineligible', 'forbidden'].includes(item.outcome)).length, + notAttempted: results.filter((item) => item.outcome === 'not_attempted').length, + hasErrors: results.some((item) => item.outcome !== 'deleted' || item.reasonCode), + }; +} diff --git a/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.http.spec.ts b/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.http.spec.ts new file mode 100644 index 000000000..d8b01ae88 --- /dev/null +++ b/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.http.spec.ts @@ -0,0 +1,97 @@ +import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing'; +import { HttpErrorResponse, HttpRequest } from '@angular/common/http'; +import { TestBed } from '@angular/core/testing'; +import { ActivationEnd } from '@angular/router'; +import { Subject, throwError } from 'rxjs'; +import { ExperimentDataService } from '../experiments/experiments.data.service'; +import { FeatureFlagsDataService } from '../feature-flags/feature-flags.data.service'; +import { SegmentsDataService } from '../segments/segments.data.service'; +import { HttpCancelInterceptor, SKIP_NAVIGATION_CANCEL } from '../http-interceptors/http-cancel.interceptor'; +import { HttpErrorInterceptor } from '../http-interceptors/http-error.interceptor'; +import { HANDLES_BATCH_ERRORS_CONTEXTUALLY } from '../http-interceptors/http-context-tokens'; +import { batchHttpContext } from './batch-actions.http'; + +describe('Batch HTTP contracts', () => { + let http: HttpTestingController; + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [HttpClientTestingModule], + providers: [ExperimentDataService, FeatureFlagsDataService, SegmentsDataService], + }); + http = TestBed.inject(HttpTestingController); + }); + afterEach(() => { + http.verify(); + TestBed.resetTestingModule(); + }); + + it.each([ + ['experiments', ExperimentDataService], + ['flags', FeatureFlagsDataService], + ['segments', SegmentsDataService], + ] as const)('%s sends the complete selection in one read or delete request', (entity, token) => { + const service = TestBed.inject(token as typeof ExperimentDataService); + for (const count of [20, 100, 500]) { + const ids = Array.from( + { length: count }, + (_, index) => `00000000-0000-4000-8000-${String(index).padStart(12, '0')}` + ); + service.checkDeletionEligibility(ids).subscribe(); + const read = http.expectOne(`/${entity}/deletion-eligibility`); + expect(read.request.method).toBe('POST'); + expect(read.request.body).toEqual({ ids }); + expect(read.request.context.get(HANDLES_BATCH_ERRORS_CONTEXTUALLY)).toBe(true); + expect(read.request.context.get(SKIP_NAVIGATION_CANCEL)).toBe(true); + read.flush({ items: [], allDeletable: false }); + service.batchDelete(ids).subscribe(); + const deletion = http.expectOne(`/${entity}/batch-delete`); + expect(deletion.request.method).toBe('POST'); + expect(deletion.request.body).toEqual({ ids }); + expect(deletion.request.context.get(SKIP_NAVIGATION_CANCEL)).toBe(true); + deletion.flush({ phase: 'executed', results: [] }); + } + }); + + it('continues observing a batch response through navigation', () => { + const navigation = new Subject(); + const response = new Subject(); + const interceptor = new HttpCancelInterceptor({ events: navigation } as any); + const received = jest.fn(); + const subscription = interceptor + .intercept(new HttpRequest('POST', '/flags/batch-delete', {}, { context: batchHttpContext() }), { + handle: () => response, + }) + .subscribe(received); + navigation.next(new ActivationEnd({} as any)); + response.next({ status: 200 }); + expect(received).toHaveBeenCalledWith({ status: 200 }); + subscription.unsubscribe(); + navigation.complete(); + }); + + it.each([0, 400, 403, 404, 500, 504])('lets the batch UI handle HTTP %i without a duplicate popup', (status) => { + const notification = { create: jest.fn() }; + const auth = { authLogout: jest.fn() }; + const interceptor = new HttpErrorInterceptor(auth as any, notification as any, {} as any); + const error = new HttpErrorResponse({ status }); + const failed = jest.fn(); + interceptor + .intercept(new HttpRequest('POST', '/flags/batch-delete', {}, { context: batchHttpContext() }), { + handle: () => throwError(() => error), + }) + .subscribe({ error: failed }); + expect(notification.create).not.toHaveBeenCalled(); + expect(failed).toHaveBeenCalledWith(error); + }); + + it('preserves automatic logout for a contextual 401', () => { + const auth = { authLogout: jest.fn() }; + const interceptor = new HttpErrorInterceptor(auth as any, { create: jest.fn() } as any, {} as any); + interceptor + .intercept(new HttpRequest('POST', '/flags/batch-delete', {}, { context: batchHttpContext() }), { + handle: () => throwError(() => new HttpErrorResponse({ status: 401 })), + }) + .subscribe({ error: () => undefined }); + expect(auth.authLogout).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.http.ts b/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.http.ts new file mode 100644 index 000000000..c6e97e04e --- /dev/null +++ b/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.http.ts @@ -0,0 +1,6 @@ +import { HttpContext } from '@angular/common/http'; +import { SKIP_NAVIGATION_CANCEL } from '../http-interceptors/http-cancel.interceptor'; +import { HANDLES_BATCH_ERRORS_CONTEXTUALLY } from '../http-interceptors/http-context-tokens'; + +export const batchHttpContext = () => + new HttpContext().set(SKIP_NAVIGATION_CANCEL, true).set(HANDLES_BATCH_ERRORS_CONTEXTUALLY, true); diff --git a/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.integration.spec.ts b/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.integration.spec.ts new file mode 100644 index 000000000..9fad5b55b --- /dev/null +++ b/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.integration.spec.ts @@ -0,0 +1,454 @@ +import { TestBed, fakeAsync, tick } from '@angular/core/testing'; +import { Actions } from '@ngrx/effects'; +import { Action, ScannedActionsSubject, Store, StoreModule } from '@ngrx/store'; +import { Subject, Subscription, of, throwError } from 'rxjs'; +import { + DeletionReasonCode, + EXPERIMENT_STATE, + FEATURE_FLAG_STATUS, + SEGMENT_STATUS, + SEGMENT_TYPE, + UserRole, +} from 'upgrade_types'; +import * as experimentActions from '../experiments/store/experiments.actions'; +import * as flagActions from '../feature-flags/store/feature-flags.actions'; +import * as segmentActions from '../segments/store/segments.actions'; +import { experimentsReducer } from '../experiments/store/experiments.reducer'; +import { featureFlagsReducer } from '../feature-flags/store/feature-flags.reducer'; +import { segmentsReducer } from '../segments/store/segments.reducer'; +import { ExperimentEffects } from '../experiments/store/experiments.effects'; +import { FeatureFlagsEffects } from '../feature-flags/store/feature-flags.effects'; +import { SegmentsEffects } from '../segments/store/segments.effects'; +import { actionLogoutStart, actionSetUserInfo } from '../auth/store/auth.actions'; +import { batchResultCounts, selectionItem, selectionView } from './batch-actions.helpers'; +import { RootBatchState, newBatchRequestId } from './batch-actions.models'; + +const fixtures = [ + { + entity: 'experiments', + key: 'experiments', + actions: experimentActions, + fetchEffect: 'getPaginatedExperiment$', + fetchMethod: 'getAllExperiment', + fetch: experimentActions.actionGetExperiments, + }, + { + entity: 'flags', + key: 'featureFlags', + actions: flagActions, + fetchEffect: 'fetchFeatureFlags$', + fetchMethod: 'fetchFeatureFlagsPaginated', + fetch: flagActions.actionFetchFeatureFlags, + }, + { + entity: 'segments', + key: 'segments', + actions: segmentActions, + fetchEffect: 'fetchSegmentsPaginated$', + fetchMethod: 'fetchSegmentsPaginated', + fetch: segmentActions.actionFetchSegments, + }, +] as const; + +describe.each(fixtures)('$entity batch store/effects integration', (config) => { + let store: Store; + let state: any; + let subscriptions: Subscription; + let events: Action[]; + let data: any; + let response: Subject; + let effects: any; + let router: any; + let notifications: any; + const actions = config.actions.batchActions; + const rows = ['a', 'b', 'c'].map((name, index) => ({ + id: `11111111-2222-4333-8444-${String(index + 1).padStart(12, '0')}`, + name, + state: config.entity === 'experiments' ? EXPERIMENT_STATE.ENROLLING : undefined, + status: config.entity === 'segments' ? SEGMENT_STATUS.UNUSED : FEATURE_FLAG_STATUS.DISABLED, + type: SEGMENT_TYPE.PUBLIC, + })); + const batch = (): RootBatchState => state[config.key].rootBatch; + const currentRows = () => state[config.key][config.entity === 'flags' ? 'featureFlags' : config.entity]; + const page = (items = rows) => + config.entity === 'segments' + ? { + total: items.length, + nodes: { + segmentsData: items, + experimentSegmentInclusionData: [], + experimentSegmentExclusionData: [], + featureFlagSegmentInclusionData: [], + featureFlagSegmentExclusionData: [], + allParentSegments: [], + }, + } + : { total: items.length, nodes: items }; + const eligibility = (ids: string[], absent: string[] = []) => ({ + allDeletable: !absent.length, + items: ids.map((id) => + absent.includes(id) + ? { id, availability: 'not_found', canDelete: false, reasonCode: DeletionReasonCode.NOT_FOUND } + : { ...selectionItem(rows.find((row) => row.id === id)), availability: 'present', canDelete: true } + ), + }); + const selectRows = (count = rows.length) => + rows.slice(0, count).forEach((row) => store.dispatch(actions.toggleRow({ item: selectionItem(row) }))); + const prepare = () => { + store.dispatch(actions.refreshEligibility({ requestId: newBatchRequestId(), forConfirmation: true })); + expect(batch().confirmation).not.toBeNull(); + return batch().confirmation; + }; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [ + StoreModule.forRoot({ + experiments: experimentsReducer, + featureFlags: featureFlagsReducer, + segments: segmentsReducer, + }), + ], + }); + store = TestBed.inject(Store); + subscriptions = new Subscription(); + subscriptions.add(store.subscribe((value) => (state = value))); + const events$ = new Actions(TestBed.inject(ScannedActionsSubject)); + events = []; + subscriptions.add(events$.subscribe((action) => events.push(action))); + response = new Subject(); + data = { + checkDeletionEligibility: jest.fn((ids) => of(eligibility(ids))), + batchDelete: jest.fn(() => response), + [config.fetchMethod]: jest.fn(() => of(page(rows.filter((row) => !batch().removedIds.includes(row.id))))), + }; + router = { navigate: jest.fn() }; + notifications = { showSuccess: jest.fn(), showWarning: jest.fn(), showError: jest.fn(), showInfo: jest.fn() }; + const translate = { instant: jest.fn((key) => key) }; + effects = + config.entity === 'experiments' + ? new ExperimentEffects( + events$, + store as any, + data, + router, + translate as any, + notifications, + {} as any, + {} as any, + {} as any + ) + : config.entity === 'flags' + ? new FeatureFlagsEffects( + store as any, + events$, + data, + router, + notifications, + translate as any, + {} as any, + {} as any + ) + : new SegmentsEffects( + store as any, + events$, + data, + {} as any, + router, + notifications, + translate as any, + {} as any + ); + for (const field of [ + 'focusBatchEligibility$', + 'batchEligibility$', + 'batchDelete$', + 'reconcileBatch$', + 'finishBatch$', + 'refreshBatchEligibility$', + 'reconcileMissingSelections$', + config.fetchEffect, + ]) { + subscriptions.add(effects[field].subscribe((action) => store.dispatch(action))); + } + store.dispatch(actionSetUserInfo({ user: { email: 'review@example.com', role: UserRole.ADMIN } })); + store.dispatch(config.fetch({ fromStarting: true })); + }); + afterEach(() => { + subscriptions.unsubscribe(); + TestBed.resetTestingModule(); + }); + + it('selects loaded rows, retains hidden selections across replacement reads, and clears all from the header', () => { + expect(batch().loadedIds).toHaveLength(3); // Also exercises NgRx's queued list-start dispatch. + selectRows(2); + data[config.fetchMethod].mockReturnValueOnce(of(page([rows[0]]))); + store.dispatch(config.fetch({ fromStarting: true })); + expect(selectionView(batch(), config.entity)).toMatchObject({ + selectedCount: 2, + checked: true, + indeterminate: false, + notShownCount: 1, + }); + store.dispatch(actions.toggleHeader({ items: [selectionItem(rows[0])] })); + expect(Object.keys(batch().selectedById)).toEqual([]); + }); + + it('keeps newly appended rows unselected and deduplicates overlapping responses', () => { + data[config.fetchMethod].mockReturnValueOnce(of({ ...page(rows.slice(0, 2)), total: 3 })); + store.dispatch(config.fetch({ fromStarting: true })); + store.dispatch(actions.toggleHeader({ items: rows.map(selectionItem) })); + data[config.fetchMethod].mockReturnValueOnce(of({ ...page(rows.slice(1)), total: 3 })); + store.dispatch(config.fetch({ fromStarting: false })); + expect(data[config.fetchMethod]).toHaveBeenLastCalledWith(expect.objectContaining({ skip: 2 }), false); + expect(batch().loadedIds).toHaveLength(3); + expect(currentRows()).toHaveLength(3); + expect(selectionView(batch(), config.entity)).toMatchObject({ + selectedCount: 2, + checked: false, + indeterminate: true, + }); + data[config.fetchMethod].mockReturnValueOnce(of(page([rows[0], rows[0]]))); + store.dispatch(config.fetch({ fromStarting: true })); + expect(currentRows()).toHaveLength(1); + }); + + it('coalesces focus checks with confirmation and ignores an obsolete response after deselection', () => { + selectRows(); + const check = new Subject(); + data.checkDeletionEligibility.mockReturnValue(check); + store.dispatch(actions.refreshEligibility({ requestId: newBatchRequestId(), forConfirmation: false })); + store.dispatch(actions.refreshEligibility({ requestId: newBatchRequestId(), forConfirmation: true })); + expect(data.checkDeletionEligibility).toHaveBeenCalledTimes(1); + check.next(eligibility(rows.map((row) => row.id))); + expect(batch().confirmation.items).toHaveLength(3); + store.dispatch(actions.refreshEligibility({ requestId: newBatchRequestId(), forConfirmation: true })); + store.dispatch(actions.toggleRow({ item: selectionItem(rows[0]) })); + check.next(eligibility(rows.map((row) => row.id))); + expect(batch().confirmation).toBeNull(); + expect(batch().selectedById[rows[0].id]).toBeUndefined(); + }); + + it('coalesces window-focus notifications and skips checks during deletion', fakeAsync(() => { + window.dispatchEvent(new Event('focus')); + tick(100); + expect(data.checkDeletionEligibility).not.toHaveBeenCalled(); + selectRows(); + window.dispatchEvent(new Event('focus')); + window.dispatchEvent(new Event('focus')); + tick(100); + expect(data.checkDeletionEligibility).toHaveBeenCalledTimes(1); + const snapshot = prepare(); + store.dispatch(actions.batchDeleteRequested({ snapshot })); + window.dispatchEvent(new Event('focus')); + tick(100); + expect(data.checkDeletionEligibility).toHaveBeenCalledTimes(2); + })); + + it('keeps selections on a failed metadata read and blocks confirmation for any hidden restriction', () => { + selectRows(); + data.checkDeletionEligibility.mockReturnValueOnce(throwError(() => new Error('offline'))); + store.dispatch(actions.refreshEligibility({ requestId: newBatchRequestId(), forConfirmation: true })); + expect(batch().eligibility.status).toBe('failed'); + expect(Object.keys(batch().selectedById)).toHaveLength(3); + const result = eligibility(rows.map((row) => row.id)); + result.items[2] = { ...result.items[2], canDelete: false, reasonCode: DeletionReasonCode.MISSING_PERMISSION }; + data.checkDeletionEligibility.mockReturnValueOnce(of(result)); + store.dispatch(actions.refreshEligibility({ requestId: newBatchRequestId(), forConfirmation: true })); + expect(batch().confirmation).toBeNull(); + expect(batch().eligibility.allDeletable).toBe(false); + expect(data.batchDelete).not.toHaveBeenCalled(); + }); + + it('removes only authoritative absences from an eligibility check and reports them separately', () => { + selectRows(); + data.checkDeletionEligibility.mockReturnValueOnce( + of( + eligibility( + rows.map((row) => row.id), + [rows[1].id] + ) + ) + ); + store.dispatch(actions.refreshEligibility({ requestId: newBatchRequestId(), forConfirmation: true })); + expect(batch().selectedById[rows[1].id]).toBeUndefined(); + expect(currentRows().some((row) => row.id === rows[1].id)).toBe(false); + expect(notifications.showInfo).toHaveBeenCalledTimes(1); + expect(data[config.fetchMethod]).toHaveBeenLastCalledWith(expect.objectContaining({ skip: 0 }), true); + expect(batch().confirmation).toBeNull(); + }); + + it('freezes the request, rejects duplicate submits, and preserves failures while refreshing the current query', () => { + selectRows(); + const snapshot = prepare(); + store.dispatch(actions.batchDeleteRequested({ snapshot })); + store.dispatch(actions.batchDeleteRequested({ snapshot: { ...snapshot, operationId: 'duplicate' } })); + store.dispatch(actions.clearSelection()); + store.dispatch(config.actions.actionSetSearchString({ searchString: 'latest query' })); + expect(data.batchDelete).toHaveBeenCalledTimes(1); + expect(data.batchDelete).toHaveBeenCalledWith(rows.map((row) => row.id)); + response.next({ + phase: 'executed', + results: rows.map((row, index) => ({ + id: row.id, + outcome: index === 0 ? 'deleted' : index === 1 ? 'failed' : 'not_attempted', + })), + }); + expect(batch().selectedById[rows[0].id]).toBeUndefined(); + expect(Object.keys(batch().selectedById)).toEqual([rows[1].id, rows[2].id]); + expect(data[config.fetchMethod]).toHaveBeenLastCalledWith( + expect.objectContaining({ skip: 0, searchParams: expect.objectContaining({ string: 'latest query' }) }), + true + ); + expect(notifications.showWarning).toHaveBeenCalledTimes(1); + expect(router.navigate).not.toHaveBeenCalled(); + }); + + it('keeps the displayed confirmation snapshot when background rows and focus refreshes arrive', fakeAsync(() => { + selectRows(2); + const snapshot = prepare(); + data[config.fetchMethod].mockReturnValueOnce(of(page([{ ...rows[0], name: 'renamed' }]))); + store.dispatch(config.fetch({ fromStarting: true })); + window.dispatchEvent(new Event('focus')); + tick(100); + expect(batch().confirmation).toBe(snapshot); + expect(snapshot.items.map(({ name }) => name)).toEqual(['a', 'b']); + expect(batch().selectedById[rows[0].id].name).toBe('renamed'); + expect(data.checkDeletionEligibility).toHaveBeenCalledTimes(1); + store.dispatch(actions.batchDeleteRequested({ snapshot })); + expect(data.batchDelete).toHaveBeenCalledWith([rows[0].id, rows[1].id]); + })); + + it('does not restore rows from a list response that started before deletion', () => { + selectRows(1); + const snapshot = prepare(); + const pending = new Subject(); + data[config.fetchMethod].mockReturnValueOnce(pending); + store.dispatch(config.fetch({ fromStarting: true })); + const oldRequestId = batch().listRequestId; + store.dispatch(actions.batchDeleteRequested({ snapshot })); + response.next({ phase: 'executed', results: [{ id: rows[0].id, outcome: 'deleted' }] }); + pending.next(page()); + const oldSuccess = events.find( + (action: any) => action.batchListRequestId && action.type.includes('Success') + ) as any; + store.dispatch({ ...oldSuccess, batchListRequestId: oldRequestId }); + expect(currentRows().some((row) => row.id === rows[0].id)).toBe(false); + expect(notifications.showSuccess).toHaveBeenCalledTimes(1); + expect(notifications.showSuccess).toHaveBeenCalledWith(`batch-delete.success.${config.entity}.one`); + }); + + it('reconciles a lost response with one bulk read, counts absence separately, and never retries deletion', () => { + selectRows(); + const snapshot = prepare(); + const reconcile = new Subject(); + data.checkDeletionEligibility.mockReturnValueOnce(reconcile); + store.dispatch(actions.batchDeleteRequested({ snapshot })); + response.error({ status: 0 }); + expect(batch().operation.status).toBe('reconciling'); + expect(Object.keys(batch().selectedById)).toHaveLength(3); + reconcile.next( + eligibility( + rows.map((row) => row.id), + [rows[0].id] + ) + ); + expect(batchResultCounts(batch())).toMatchObject({ deleted: 0, absent: 1, remaining: 2, uncertain: true }); + expect(data.checkDeletionEligibility.mock.calls[1][0]).toEqual(rows.map((row) => row.id)); + expect(data.batchDelete).toHaveBeenCalledTimes(1); + expect(notifications.showWarning).toHaveBeenCalledTimes(1); + }); + + it('retains all unknown items if reconciliation fails and distinguishes a subsequent list-refresh failure', () => { + selectRows(); + const snapshot = prepare(); + data.checkDeletionEligibility.mockReturnValue(throwError(() => new Error('metadata unavailable'))); + data[config.fetchMethod].mockReturnValue(throwError(() => new Error('list unavailable'))); + store.dispatch(actions.batchDeleteRequested({ snapshot })); + response.error({ status: 504 }); + expect(batch().operation.status).toBe('complete'); + expect(batch().operation.reconciliationFailed).toBe(true); + expect(batch().listRefreshFailed).toBe(true); + expect(Object.keys(batch().selectedById)).toHaveLength(3); + expect(data.batchDelete).toHaveBeenCalledTimes(1); + }); + + it('reports a preflight rejection without removing any selected or hidden row', () => { + selectRows(); + const snapshot = prepare(); + store.dispatch(actions.batchDeleteRequested({ snapshot })); + response.next({ + phase: 'rejected', + results: rows.map(({ id }, index) => ({ id, outcome: index === 2 ? 'ineligible' : 'not_attempted' })), + }); + expect(currentRows()).toHaveLength(3); + expect(Object.keys(batch().selectedById)).toHaveLength(3); + expect(batch().operation.result.phase).toBe('rejected'); + expect(notifications.showWarning).toHaveBeenCalledTimes(1); + }); + + it('keeps confirmed deletion when the post-commit cleanup or list refresh fails', () => { + selectRows(1); + const snapshot = prepare(); + data[config.fetchMethod].mockReturnValue(throwError(() => new Error('refresh unavailable'))); + store.dispatch(actions.batchDeleteRequested({ snapshot })); + response.next({ + phase: 'executed', + results: [{ id: rows[0].id, outcome: 'deleted', reasonCode: 'post_delete_failed' }], + }); + expect(batch().selectedById[rows[0].id]).toBeUndefined(); + expect(batch().operation.result.results[0].outcome).toBe('deleted'); + expect(batch().listRefreshFailed).toBe(true); + expect(notifications.showWarning).toHaveBeenCalledTimes(1); + }); + + it('treats a malformed successful response as unknown and reconciles instead of retrying', () => { + selectRows(); + const snapshot = prepare(); + store.dispatch(actions.batchDeleteRequested({ snapshot })); + response.next({ phase: 'executed', results: [] }); + expect(batch().operation.result.results.every((result) => result.outcome === 'unknown')).toBe(true); + expect(Object.keys(batch().selectedById)).toHaveLength(3); + expect(data.batchDelete).toHaveBeenCalledTimes(1); + }); + + it('requires a new confirmation and sends only retained IDs on an explicit retry', () => { + selectRows(); + const first = prepare(); + store.dispatch(actions.batchDeleteRequested({ snapshot: first })); + response.next({ + phase: 'executed', + results: rows.map(({ id }, index) => ({ id, outcome: index === 0 ? 'deleted' : 'not_attempted' })), + }); + response.complete(); + store.dispatch(actions.batchDeleteRequested({ snapshot: first })); + expect(data.batchDelete).toHaveBeenCalledTimes(1); + const second = prepare(); + expect(second.operationId).not.toBe(first.operationId); + data.batchDelete.mockReturnValue(new Subject()); + store.dispatch(actions.batchDeleteRequested({ snapshot: second })); + expect(data.batchDelete).toHaveBeenLastCalledWith([rows[1].id, rows[2].id]); + }); + + it('clears state on logout and ignores a late deletion response in the next session', () => { + selectRows(); + const snapshot = prepare(); + store.dispatch(actions.batchDeleteRequested({ snapshot })); + store.dispatch(actionLogoutStart()); + store.dispatch(actionSetUserInfo({ user: { email: 'next@example.com', role: UserRole.ADMIN } })); + response.next({ phase: 'executed', results: rows.map(({ id }) => ({ id, outcome: 'deleted' })) }); + expect(batch().operation).toBeNull(); + expect(Object.keys(batch().selectedById)).toHaveLength(0); + expect(batch().removedIds).toHaveLength(0); + expect(notifications.showSuccess).not.toHaveBeenCalled(); + }); + + it('retains selection but invalidates confirmation when the role changes', () => { + selectRows(); + prepare(); + store.dispatch(actionSetUserInfo({ user: { email: 'review@example.com', role: UserRole.READER } })); + expect(Object.keys(batch().selectedById)).toHaveLength(3); + expect(batch().confirmation).toBeNull(); + expect(selectionView(batch(), config.entity).reasonCode).toBe(DeletionReasonCode.MISSING_PERMISSION); + }); +}); diff --git a/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.invalidation.ts b/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.invalidation.ts new file mode 100644 index 000000000..da5831910 --- /dev/null +++ b/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.invalidation.ts @@ -0,0 +1,47 @@ +import * as auth from '../auth/store/auth.actions'; +import * as experiments from '../experiments/store/experiments.actions'; + +import * as flags from '../feature-flags/store/feature-flags.actions'; + +import * as segments from '../segments/store/segments.actions'; + +// These writes can change a selected name, state, or segment usage through an owner/list. +export const batchInvalidationTypes: string[] = [ + experiments.batchActions.batchDeleteCompleted.type, + flags.batchActions.batchDeleteCompleted.type, + segments.batchActions.batchDeleteCompleted.type, + auth.actionSetUserInfo.type, + auth.actionSetUserInfoSuccess.type, + auth.actionLoginSuccess.type, + experiments.actionUpsertExperimentSuccess.type, + experiments.actionDeleteExperimentSuccess.type, + experiments.actionUpdateExperimentStateSuccess.type, + experiments.actionUpdateExperimentFilterModeSuccess.type, + experiments.actionImportExperimentSuccess.type, + experiments.actionAddExperimentInclusionListSuccess.type, + experiments.actionUpdateExperimentInclusionListSuccess.type, + experiments.actionDeleteExperimentInclusionListSuccess.type, + experiments.actionAddExperimentExclusionListSuccess.type, + experiments.actionUpdateExperimentExclusionListSuccess.type, + experiments.actionDeleteExperimentExclusionListSuccess.type, + flags.actionAddFeatureFlagSuccess.type, + flags.actionDeleteFeatureFlagSuccess.type, + flags.actionUpdateFeatureFlagSuccess.type, + flags.actionUpdateFeatureFlagStatusSuccess.type, + flags.actionUpdateFilterModeSuccess.type, + flags.actionAddFeatureFlagInclusionListSuccess.type, + flags.actionUpdateFeatureFlagInclusionListSuccess.type, + flags.actionUpdateFeatureFlagInclusionListStatusSuccess.type, + flags.actionDeleteFeatureFlagInclusionListSuccess.type, + flags.actionAddFeatureFlagExclusionListSuccess.type, + flags.actionUpdateFeatureFlagExclusionListSuccess.type, + flags.actionUpdateFeatureFlagExclusionListStatusSuccess.type, + flags.actionDeleteFeatureFlagExclusionListSuccess.type, + segments.actionUpsertSegmentSuccess.type, + segments.actionAddSegmentSuccess.type, + segments.actionUpdateSegmentSuccess.type, + segments.actionDeleteSegmentSuccess.type, + segments.actionAddSegmentListSuccess.type, + segments.actionUpdateSegmentListSuccess.type, + segments.actionDeleteSegmentListSuccess.type, +]; diff --git a/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.models.ts b/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.models.ts new file mode 100644 index 000000000..5881392b1 --- /dev/null +++ b/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.models.ts @@ -0,0 +1,62 @@ +import { BatchDeleteResult, DeletionEligibilityItem, UserRole } from 'upgrade_types'; + +export type RootSelectionItem = Pick & { + availability?: DeletionEligibilityItem['availability']; + reasonCode?: DeletionEligibilityItem['reasonCode']; +}; + +export interface BatchDeleteSnapshot { + operationId: string; + revision: number; + items: RootSelectionItem[]; + notShownCount: number; +} + +export interface RootBatchState { + selectedById: Record; + revision: number; + userEmail: string | null; + role: UserRole | null; + loadedIds: string[]; + listRequestId: string | null; + listLoading: boolean; + listRefreshFailed: boolean; + removedIds: string[]; + eligibility: { + status: 'idle' | 'checking' | 'ready' | 'failed'; + requestId?: string; + revision?: number; + forConfirmation?: boolean; + allDeletable?: boolean; + absentIds?: string[]; + }; + confirmation: BatchDeleteSnapshot | null; + operation: { + snapshot: BatchDeleteSnapshot; + status: 'submitting' | 'reconciling' | 'complete'; + result?: BatchDeleteResult; + transportStatus?: number; + reconciledAbsentIds: string[]; + reconciliationFailed?: boolean; + } | null; +} + +export const initialRootBatchState: RootBatchState = { + selectedById: {}, + revision: 0, + userEmail: null, + role: null, + loadedIds: [], + listRequestId: null, + listLoading: false, + listRefreshFailed: false, + removedIds: [], + eligibility: { status: 'idle' }, + confirmation: null, + operation: null, +}; + +let requestSequence = 0; +export const newBatchRequestId = () => `batch-${++requestSequence}`; +export const isBatchBusy = (state: RootBatchState) => + state.operation?.status === 'submitting' || state.operation?.status === 'reconciling'; diff --git a/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.reducer.ts b/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.reducer.ts new file mode 100644 index 000000000..03f0eb94e --- /dev/null +++ b/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.reducer.ts @@ -0,0 +1,272 @@ +import { Action } from '@ngrx/store'; +import { BatchDeleteEntity, DeletionReasonCode, SEGMENT_TYPE, hasBatchDeletePermission } from 'upgrade_types'; +import * as auth from '../auth/store/auth.actions'; +import { RootBatchActions } from './batch-actions.actions'; +import { RootBatchState, RootSelectionItem, initialRootBatchState, isBatchBusy } from './batch-actions.models'; +import { confirmedRemovedIds, selectionView } from './batch-actions.helpers'; + +function matches( + action: Action, + creator: C +): action is ReturnType { + return action.type === creator.type; +} + +export function invalidateSelection(state: RootBatchState): RootBatchState { + return { ...state, revision: state.revision + 1, eligibility: { status: 'idle' }, confirmation: null }; +} + +function removeConfirmed(state: RootBatchState, ids: string[]): RootBatchState { + if (!ids.length) return state; + const removed = new Set(ids); + return { + ...invalidateSelection(state), + removedIds: [...new Set([...state.removedIds, ...ids])], + selectedById: Object.fromEntries(Object.entries(state.selectedById).filter(([id]) => !removed.has(id))), + loadedIds: state.loadedIds.filter((id) => !removed.has(id)), + }; +} + +export function receiveListRows( + state: RootBatchState, + items: RootSelectionItem[], + fromStarting: boolean +): RootBatchState { + const removed = new Set(state.removedIds); + const rows = items.filter((item) => !removed.has(item.id)); + const selectedById = { ...state.selectedById }; + let changed = false; + rows.forEach((item) => { + if (selectedById[item.id]) { + changed = changed || JSON.stringify(selectedById[item.id]) !== JSON.stringify(item); + selectedById[item.id] = item; + } + }); + return { + ...(changed ? invalidateSelection(state) : state), + // A background page refresh must not replace the snapshot already shown in the dialog. + confirmation: state.confirmation, + selectedById, + loadedIds: [...new Set([...(fromStarting ? [] : state.loadedIds), ...rows.map((item) => item.id)])], + listLoading: false, + listRefreshFailed: false, + }; +} + +export function reduceRootBatch( + state: RootBatchState, + action: Action, + actions: RootBatchActions, + entity: BatchDeleteEntity +): RootBatchState { + if (matches(action, auth.actionLogoutStart) || matches(action, auth.actionLogoutSuccess)) + return initialRootBatchState; + if ( + matches(action, auth.actionSetUserInfo) || + matches(action, auth.actionSetUserInfoSuccess) || + matches(action, auth.actionLoginSuccess) + ) { + const email = action.user?.email || null; + const role = action.user?.role || null; + if (state.userEmail !== email) return { ...initialRootBatchState, userEmail: email, role }; + return state.role === role + ? state + : { + ...invalidateSelection(state), + role, + selectedById: Object.fromEntries( + Object.entries(state.selectedById).map(([id, item]) => [ + id, + item.reasonCode === DeletionReasonCode.MISSING_PERMISSION ? { ...item, reasonCode: undefined } : item, + ]) + ), + }; + } + if (matches(action, actions.listRequested)) + return { + ...state, + listRequestId: action.requestId, + loadedIds: action.fromStarting ? [] : state.loadedIds, + listLoading: true, + listRefreshFailed: false, + }; + if (matches(action, actions.listFailed)) + return action.requestId === state.listRequestId + ? { ...state, listLoading: false, listRefreshFailed: action.batchRefresh } + : state; + if (matches(action, actions.invalidateEligibility)) return invalidateSelection(state); + if (matches(action, actions.confirmedRemoved)) return removeConfirmed(state, action.ids); + if ( + matches(action, actions.clearSelection) || + matches(action, actions.toggleHeader) || + matches(action, actions.toggleRow) + ) { + if (isBatchBusy(state)) return state; + let selectedById = { ...state.selectedById }; + if ( + matches(action, actions.clearSelection) || + (matches(action, actions.toggleHeader) && Object.keys(selectedById).length) + ) { + selectedById = {}; + } else { + const items = matches(action, actions.toggleRow) + ? [action.item] + : matches(action, actions.toggleHeader) && !state.listLoading + ? action.items + : []; + for (const item of items) { + if (selectedById[item.id] && matches(action, actions.toggleRow)) delete selectedById[item.id]; + else if ( + state.loadedIds.includes(item.id) && + (entity !== 'segments' || item.segmentType === SEGMENT_TYPE.PUBLIC) + ) { + selectedById[item.id] = { ...item }; + } + } + } + return { ...invalidateSelection(state), selectedById }; + } + if (matches(action, actions.refreshEligibility)) { + if (isBatchBusy(state) || !Object.keys(state.selectedById).length) return state; + if (state.confirmation && !action.forConfirmation) return state; + if (state.eligibility.status === 'checking' && state.eligibility.revision === state.revision) { + return { + ...state, + eligibility: { + ...state.eligibility, + forConfirmation: state.eligibility.forConfirmation || action.forConfirmation, + }, + }; + } + return { + ...state, + confirmation: null, + eligibility: { + status: 'checking', + requestId: action.requestId, + revision: state.revision, + forConfirmation: action.forConfirmation, + }, + }; + } + if (matches(action, actions.eligibilitySucceeded) || matches(action, actions.eligibilityFailed)) { + if (action.requestId !== state.eligibility.requestId || action.revision !== state.revision) return state; + if (matches(action, actions.eligibilityFailed)) + return { ...state, confirmation: null, eligibility: { status: 'failed' } }; + const selectedById = { ...state.selectedById }; + for (const item of action.result.items) { + if (selectedById[item.id]) { + const { id, name, stateOrStatus, segmentType, availability, reasonCode } = item; + selectedById[id] = { + id, + name: name || selectedById[id].name, + stateOrStatus, + segmentType, + availability, + reasonCode, + }; + } + } + const allDeletable = action.result.allDeletable && hasBatchDeletePermission(state.role, entity); + const confirmation = + state.eligibility.forConfirmation && allDeletable + ? { + operationId: action.requestId, + revision: state.revision, + items: Object.values(selectedById).map((item) => ({ ...item })), + notShownCount: selectionView(state, entity).notShownCount, + } + : null; + const absentIds = action.result.items.filter((item) => item.availability === 'not_found').map((item) => item.id); + return { + ...removeConfirmed({ ...state, selectedById }, absentIds), + confirmation: absentIds.length ? null : confirmation, + eligibility: { + status: 'ready', + requestId: action.requestId, + revision: action.revision, + allDeletable: allDeletable && !absentIds.length, + absentIds, + }, + }; + } + if (matches(action, actions.dismissConfirmation)) + return isBatchBusy(state) ? state : { ...state, confirmation: null }; + if (matches(action, actions.batchDeleteRequested)) { + const snapshot = state.confirmation; + if ( + isBatchBusy(state) || + !snapshot || + snapshot.operationId !== action.snapshot.operationId || + snapshot.revision !== action.snapshot.revision || + !hasBatchDeletePermission(state.role, entity) + ) + return state; + return { + ...state, + listRequestId: null, + listLoading: false, + confirmation: null, + operation: { snapshot, status: 'submitting', reconciledAbsentIds: [] }, + }; + } + if ( + matches(action, actions.batchDeleteCompleted) || + matches(action, actions.batchDeleteRequestFailed) || + matches(action, actions.reconciliationCompleted) + ) { + if ( + !state.operation || + action.operationId !== state.operation.snapshot.operationId || + state.operation.status === 'complete' + ) + return state; + if (matches(action, actions.batchDeleteCompleted)) { + const next = removeConfirmed(state, confirmedRemovedIds(action.result)); + return { + ...invalidateSelection(next), + operation: { + ...state.operation, + result: action.result, + status: action.result.results.some((item) => item.outcome === 'unknown') ? 'reconciling' : 'complete', + }, + }; + } + if (matches(action, actions.batchDeleteRequestFailed)) { + const rejected = [400, 401, 403].includes(action.status); + return { + ...invalidateSelection(state), + operation: { + ...state.operation, + transportStatus: action.status, + status: rejected ? 'complete' : 'reconciling', + result: { + phase: rejected ? 'rejected' : 'executed', + results: state.operation.snapshot.items.map(({ id }) => ({ + id, + outcome: action.status === 403 ? 'forbidden' : rejected ? 'not_attempted' : 'unknown', + reasonCode: + action.status === 403 + ? DeletionReasonCode.MISSING_PERMISSION + : rejected + ? DeletionReasonCode.DELETE_FAILED + : DeletionReasonCode.OUTCOME_UNKNOWN, + })), + }, + }, + }; + } + const absent = + action.result?.items.filter((item) => item.availability === 'not_found').map((item) => item.id) || []; + return { + ...removeConfirmed(state, absent), + operation: { + ...state.operation, + status: 'complete', + reconciledAbsentIds: absent.filter((id) => !confirmedRemovedIds(state.operation.result).includes(id)), + reconciliationFailed: !action.result, + }, + }; + } + return state; +} diff --git a/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.store.ts b/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.store.ts new file mode 100644 index 000000000..ddacfc7ca --- /dev/null +++ b/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.store.ts @@ -0,0 +1,86 @@ +import { Action, ActionReducer } from '@ngrx/store'; +import { BatchDeleteEntity } from 'upgrade_types'; +import { RootBatchActions } from './batch-actions.actions'; +import { RootBatchState, isBatchBusy } from './batch-actions.models'; +import { receiveListRows, reduceRootBatch } from './batch-actions.reducer'; +import { selectionItem } from './batch-actions.helpers'; + +interface RootListConfig { + entity: BatchDeleteEntity; + actions: RootBatchActions; + rowsKey: string; + skipKey: string; + totalKey: string; + queryTypes: string[]; + listSuccessType: string; + responseRowsKey: string; + deletedId: (action: Action) => string | undefined; +} + +/** Attach the shared selection lifecycle while preserving each entity's existing detail reducers. */ +export function withRootBatch( + reducer: ActionReducer, + initialState: S, + config: RootListConfig +): ActionReducer { + return (input, action: Action & { batchListRequestId?: string; fromStarting?: boolean }) => { + const state = input || initialState; + if (action.batchListRequestId && action.batchListRequestId !== state.rootBatch.listRequestId) return state; + let rootBatch = reduceRootBatch(state.rootBatch, action, config.actions, config.entity); + const deletedId = config.deletedId(action); + if (deletedId) + rootBatch = reduceRootBatch( + rootBatch, + config.actions.confirmedRemoved({ ids: [deletedId] }), + config.actions, + config.entity + ); + if (config.queryTypes.includes(action.type)) + rootBatch = { ...rootBatch, loadedIds: [], listRequestId: null, listLoading: false }; + const next = reducer(state, action); + if (action.type === config.listSuccessType) { + rootBatch = receiveListRows(rootBatch, action[config.responseRowsKey].map(selectionItem), !!action.fromStarting); + } else if (next[config.rowsKey] !== state[config.rowsKey]) { + const previousRows = new Map(state[config.rowsKey].map((row) => [row.id, row])); + const changed = next[config.rowsKey].filter( + (row) => previousRows.get(row.id) !== row && rootBatch.selectedById[row.id] + ); + if (changed.length) { + const updated = receiveListRows(rootBatch, changed.map(selectionItem), false); + rootBatch = { ...updated, loadedIds: rootBatch.loadedIds, listLoading: rootBatch.listLoading }; + } + } + // Tombstones also protect against late detail/stat responses, whose IDs are not tied to a root query. + const removed = new Set(rootBatch.removedIds); + let result = next; + if (action.type === config.listSuccessType) { + const rows = result[config.rowsKey]; + const distinct = new Map(rows.map((row) => [row.id, row])); + if (distinct.size !== rows.length) result = { ...result, [config.rowsKey]: [...distinct.values()] }; + } + const prune = (key: string) => { + const rows = result[key]; + if (Array.isArray(rows) && rows.some((row) => removed.has(row.id))) + result = { ...result, [key]: rows.filter((row) => !removed.has(row.id)) }; + }; + prune(config.rowsKey); + prune('allExperimentNames'); + prune('listSegmentOptions'); + if (removed.has(result['selectedFlag']?.id)) result = { ...result, selectedFlag: null }; + for (const key of ['stats', 'rewardsSummaries']) { + if (result[key] && Object.keys(result[key]).some((id) => removed.has(id))) { + result = { + ...result, + [key]: Object.fromEntries(Object.entries(result[key]).filter(([id]) => !removed.has(id))), + }; + } + } + if ( + rootBatch.removedIds.length > state.rootBatch.removedIds.length || + (isBatchBusy(state.rootBatch) && rootBatch.operation?.status === 'complete') + ) { + result = { ...result, [config.skipKey]: 0, [config.totalKey]: null }; + } + return rootBatch === state.rootBatch && result === state ? state : { ...result, rootBatch }; + }; +} diff --git a/packages/frontend/projects/upgrade/src/app/core/experiments/experiments.data.service.ts b/packages/frontend/projects/upgrade/src/app/core/experiments/experiments.data.service.ts index f601fb2ac..2c7804ff3 100644 --- a/packages/frontend/projects/upgrade/src/app/core/experiments/experiments.data.service.ts +++ b/packages/frontend/projects/upgrade/src/app/core/experiments/experiments.data.service.ts @@ -1,3 +1,5 @@ +import { BatchDeleteResult, DeletionEligibilityResult } from 'upgrade_types'; +import { batchHttpContext } from '../batch-actions/batch-actions.http'; import { Injectable } from '@angular/core'; import { Experiment, @@ -19,11 +21,27 @@ import { IImportFile, LIST_FILTER_MODE, ExperimentRewardsSummary } from 'upgrade @Injectable() export class ExperimentDataService { + checkDeletionEligibility(ids: string[]): Observable { + return this.http.post( + API_ENDPOINTS.experimentsDeletionEligibility, + { ids }, + { context: batchHttpContext() } + ); + } + + batchDelete(ids: string[]): Observable { + return this.http.post( + API_ENDPOINTS.experimentsBatchDelete, + { ids }, + { context: batchHttpContext() } + ); + } + constructor(private http: HttpClient) {} - getAllExperiment(params: ExperimentPaginationParams) { + getAllExperiment(params: ExperimentPaginationParams, batchRefresh = false) { const url = API_ENDPOINTS.getAllExperiments; - return this.http.post(url, params); + return batchRefresh ? this.http.post(url, params, { context: batchHttpContext() }) : this.http.post(url, params); } getAllExperimentsStats(experimentIds: string[]) { diff --git a/packages/frontend/projects/upgrade/src/app/core/experiments/experiments.service.ts b/packages/frontend/projects/upgrade/src/app/core/experiments/experiments.service.ts index 073a05c19..8b5df8ac7 100644 --- a/packages/frontend/projects/upgrade/src/app/core/experiments/experiments.service.ts +++ b/packages/frontend/projects/upgrade/src/app/core/experiments/experiments.service.ts @@ -1,3 +1,6 @@ +import { createBatchFacade } from '../batch-actions/batch-actions.facade'; +import { batchActions } from './store/experiments.actions'; +import { selectRootBatch } from './store/experiments.selectors'; import { Injectable } from '@angular/core'; import { Observable, combineLatest } from 'rxjs'; import { @@ -68,6 +71,8 @@ import { selectCurrentUserEmail } from '../auth/store/auth.selectors'; @Injectable() export class ExperimentService { + readonly batch = createBatchFacade(this.store$, 'experiments', batchActions, selectRootBatch, selectAllExperiment); + constructor(private readonly store$: Store, private readonly localStorageService: LocalStorageService) {} experiments$: Observable = this.store$.pipe(select(selectAllExperiment)); diff --git a/packages/frontend/projects/upgrade/src/app/core/experiments/store/experiments.actions.ts b/packages/frontend/projects/upgrade/src/app/core/experiments/store/experiments.actions.ts index ff95af409..2cee9ec68 100644 --- a/packages/frontend/projects/upgrade/src/app/core/experiments/store/experiments.actions.ts +++ b/packages/frontend/projects/upgrade/src/app/core/experiments/store/experiments.actions.ts @@ -1,3 +1,4 @@ +import { createBatchActions } from '../../batch-actions/batch-actions.actions'; import { createAction, props } from '@ngrx/store'; import { PAGE_ERROR_TYPE } from '@shared-component-lib/common-page-error/common-page-error.model'; import { @@ -21,11 +22,14 @@ import { import { ExperimentSegmentListRequest } from '../../segments/store/segments.model'; import { ExperimentRewardsSummary } from 'upgrade_types'; -export const actionGetExperiments = createAction('[Experiment] Get Experiments', props<{ fromStarting?: boolean }>()); +export const actionGetExperiments = createAction( + '[Experiment] Get Experiments', + props<{ fromStarting?: boolean; batchRefresh?: boolean }>() +); export const actionGetExperimentsSuccess = createAction( '[Experiment] Get Experiments Success', - props<{ experiments: Experiment[]; totalExperiments: number; fromStarting?: boolean }>() + props<{ batchListRequestId?: string; experiments: Experiment[]; totalExperiments: number; fromStarting?: boolean }>() ); export const actionGetExperimentsFailure = createAction('[Experiment] Get Experiment Failure', props<{ error: any }>()); @@ -428,3 +432,5 @@ export const actionFetchRewardsDataForExperimentFailure = createAction( '[Experiment] Fetch Rewards Data For Experiment Failure', props<{ error: any }>() ); + +export const batchActions = createBatchActions('experiments'); 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 b8117f129..9964b0c5c 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 @@ -1,3 +1,5 @@ +import { initialState as initialExperimentState } from './experiments.reducer'; +import { batchActions } from './experiments.actions'; import { fakeAsync, tick } from '@angular/core/testing'; import { ActionsSubject } from '@ngrx/store'; import { BehaviorSubject, of, throwError, timer } from 'rxjs'; @@ -26,7 +28,6 @@ import { actionFetchExperimentDetailStatSuccess, actionFetchExperimentDetailStat, actionGetExperimentsSuccess, - actionSetSkipExperiment, actionFetchExperimentGraphInfo, actionUpsertExperiment, actionUpsertExperimentFailure, @@ -61,14 +62,7 @@ import { actionAddExperimentExclusionListSuccess, } from './experiments.actions'; import { ExperimentEffects } from './experiments.effects'; -import { - DATE_RANGE, - EXPERIMENT_SEARCH_KEY, - SORT_AS_DIRECTION, - EXPERIMENT_SORT_KEY, - EXPERIMENT_STATE, - UpsertExperimentType, -} from './experiments.model'; +import { DATE_RANGE, EXPERIMENT_SEARCH_KEY, EXPERIMENT_STATE, UpsertExperimentType } from './experiments.model'; import * as Selectors from './experiments.selectors'; import { environment } from '../../../../environments/environment'; import { actionExecuteQuery, actionFetchMetrics } from '../../analysis/store/analysis.actions'; @@ -130,122 +124,47 @@ describe('ExperimentEffects', () => { }); describe('#getPaginatedExperiment$', () => { - it('should catch and dispatch actionGetExperimentsFailure on error and exercise skip { - experimentDataService.getAllExperiment = jest.fn().mockReturnValue(throwError('testError')); - Selectors.selectSkipExperiment.setResult(0); - Selectors.selectTotalExperiment.setResult(1); - Selectors.selectSearchKey.setResult(EXPERIMENT_SEARCH_KEY.ALL); - Selectors.selectSortKey.setResult(EXPERIMENT_SORT_KEY.UPDATED_AT); - Selectors.selectSortAs.setResult(SORT_AS_DIRECTION.ASCENDING); - Selectors.selectSearchString.setResult('test'); - - service.getPaginatedExperiment$.subscribe((result: any) => { - tick(0); - - const failureAction = actionGetExperimentsFailure(result); - - expect(result).toEqual(failureAction); - }); - - actions$.next(actionGetExperiments({})); - })); - - it('should catch and dispatch actionGetExperimentsFailure on error and exercise total=null filter', fakeAsync(() => { - experimentDataService.getAllExperiment = jest.fn().mockReturnValue(throwError('testError')); - Selectors.selectSkipExperiment.setResult(0); - Selectors.selectTotalExperiment.setResult(null); - Selectors.selectSearchKey.setResult(EXPERIMENT_SEARCH_KEY.ALL); - Selectors.selectSortKey.setResult(EXPERIMENT_SORT_KEY.UPDATED_AT); - Selectors.selectSortAs.setResult(SORT_AS_DIRECTION.ASCENDING); - Selectors.selectSearchString.setResult('test'); - - service.getPaginatedExperiment$.subscribe((result: any) => { - tick(0); - - const failureAction = actionGetExperimentsFailure(result); - - expect(result).toEqual(failureAction); - }); - - actions$.next(actionGetExperiments({})); - })); - - it('should dispatch actionGetExperimentsSuccess and actionFetchExperimentStats when fromStaring is undefined', fakeAsync(() => { - const experiments = [ - { - id: 'test1', - } as any, - ]; - - const experimentIds = ['test1']; - const totalExperiments = 1; - - experimentDataService.getAllExperiment = jest.fn().mockReturnValue(of({ nodes: experiments, total: 1 })); - Selectors.selectSkipExperiment.setResult(0); - Selectors.selectTotalExperiment.setResult(1); - Selectors.selectSearchKey.setResult(EXPERIMENT_SEARCH_KEY.ALL); - Selectors.selectSortKey.setResult(EXPERIMENT_SORT_KEY.UPDATED_AT); - Selectors.selectSortAs.setResult(SORT_AS_DIRECTION.ASCENDING); - Selectors.selectSearchString.setResult('test'); - - service.getPaginatedExperiment$.pipe(take(2), pairwise()).subscribe((result: any) => { - tick(0); - - const successAction = actionGetExperimentsSuccess({ experiments, totalExperiments }); - const fetchAction = actionFetchExperimentStats({ experimentIds }); - - expect(result).toEqual([successAction, fetchAction]); - }); + beforeEach(() => { + store$.next({ experiments: { ...initialExperimentState, totalExperiments: 1, searchString: 'test' } }); + }); + it.each([1, null])('reports a tracked list failure when total is %s', (totalExperiments) => { + store$.next({ experiments: { ...initialExperimentState, totalExperiments } }); + experimentDataService.getAllExperiment = jest.fn().mockReturnValue(throwError(() => 'testError')); + const results = []; + const subscription = service.getPaginatedExperiment$.subscribe((result) => results.push(result)); actions$.next(actionGetExperiments({})); - tick(0); - })); - - it('should dispatch actionSetSkipExperiment, actionGetExperimentsSuccess and actionFetchExperimentStats when fromStaring is true', fakeAsync(() => { - const experiments = [ - { - id: 'test1', - } as any, - ]; + expect(results).toEqual([ + batchActions.listFailed({ requestId: expect.any(String), batchRefresh: false }), + actionGetExperimentsFailure({ error: null }), + ]); + subscription.unsubscribe(); + }); - const experimentIds = ['test1']; - const totalExperiments = 1; - - experimentDataService.getAllExperiment = jest.fn().mockReturnValue(of({ nodes: experiments, total: 1 })); - Selectors.selectSkipExperiment.setResult(2); - Selectors.selectTotalExperiment.setResult(1); - Selectors.selectSearchKey.setResult(EXPERIMENT_SEARCH_KEY.ALL); - Selectors.selectSortKey.setResult(EXPERIMENT_SORT_KEY.UPDATED_AT); - Selectors.selectSortAs.setResult(SORT_AS_DIRECTION.ASCENDING); - Selectors.selectSearchString.setResult('test'); - - service.getPaginatedExperiment$ - .pipe( - take(3), - scan((acc, val) => { - acc.unshift(val); - acc.splice(3); - return acc; - }, []), - last() - ) - .subscribe((result: any) => { - tick(0); - - const skipAction = actionSetSkipExperiment({ skipExperiment: 0 }); - const successAction = actionGetExperimentsSuccess({ + it.each([false, true])( + 'correlates root rows with the request and fetches stats (replacement=%s)', + (fromStarting) => { + const experiments = [{ id: 'test1' } as any]; + experimentDataService.getAllExperiment = jest.fn().mockReturnValue(of({ nodes: experiments, total: 1 })); + const results = []; + const subscription = service.getPaginatedExperiment$.subscribe((result) => results.push(result)); + actions$.next(actionGetExperiments({ fromStarting })); + expect(results).toEqual([ + actionGetExperimentsSuccess({ experiments, - fromStarting: true, - totalExperiments, - }); - const fetchAction = actionFetchExperimentStats({ experimentIds }); - - expect(result.reverse()).toEqual([skipAction, successAction, fetchAction]); - }); - - actions$.next(actionGetExperiments({ fromStarting: true })); - tick(0); - })); + totalExperiments: 1, + fromStarting, + batchListRequestId: expect.any(String), + }), + actionFetchExperimentStats({ experimentIds: ['test1'] }), + ]); + expect(experimentDataService.getAllExperiment).toHaveBeenCalledWith( + expect.objectContaining({ skip: 0, searchParams: { key: initialExperimentState.searchKey, string: 'test' } }), + false + ); + subscription.unsubscribe(); + } + ); }); describe('#fetchExperimentStatsForHome$', () => { 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 a881f06bd..a1ec0650c 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 @@ -1,3 +1,18 @@ +import { + selectionFocusEffect, + eligibilityAbsenceEffect, + eligibilityEffect, + batchDeleteEffect, + reconcileBatchEffect, + batchFinishedEffect, + refreshSelectedEffect, + trackedListRequest, +} from '../../batch-actions/batch-actions.effects'; +import { isBatchBusy, newBatchRequestId } from '../../batch-actions/batch-actions.models'; +import { batchResultCounts } from '../../batch-actions/batch-actions.helpers'; +import { selectRootBatch, selectExperimentState } from './experiments.selectors'; +import { batchInvalidationTypes } from '../../batch-actions/batch-actions.invalidation'; +import { actionFetchListSegmentOptions } from '../../segments/store/segments.actions'; import { Inject, Injectable } from '@angular/core'; import { Actions, createEffect, ofType } from '@ngrx/effects'; import * as experimentAction from './experiments.actions'; @@ -9,7 +24,6 @@ import { IExperimentEnrollmentStats, Experiment, NUMBER_OF_EXPERIMENTS, - ExperimentPaginationParams, IExperimentEnrollmentDetailStats, IContextMetaData, } from './experiments.model'; @@ -18,11 +32,6 @@ import { Store, select } from '@ngrx/store'; import { AppState, NotificationService } from '../../core.module'; import { selectExperimentStats, - selectSkipExperiment, - selectSearchKey, - selectSortAs, - selectSortKey, - selectTotalExperiment, selectSearchString, selectExperimentGraphInfo, selectContextMetaData, @@ -39,6 +48,83 @@ import { LIST_FILTER_MODE } from 'upgrade_types'; import { LIST_OPTION_TYPE } from '../../segments/store/segments.model'; @Injectable() export class ExperimentEffects { + focusBatchEligibility$ = createEffect(() => + selectionFocusEffect(this.store$.pipe(select(selectRootBatch)), experimentAction.batchActions) + ); + batchEligibility$ = createEffect(() => + eligibilityEffect( + this.actions$, + this.store$.pipe(select(selectRootBatch)), + experimentAction.batchActions, + this.experimentDataService + ) + ); + batchDelete$ = createEffect(() => + batchDeleteEffect( + this.actions$, + this.store$.pipe(select(selectRootBatch)), + experimentAction.batchActions, + this.experimentDataService + ) + ); + reconcileBatch$ = createEffect(() => + reconcileBatchEffect( + this.actions$, + this.store$.pipe(select(selectRootBatch)), + experimentAction.batchActions, + this.experimentDataService + ) + ); + refreshBatchEligibility$ = createEffect(() => + refreshSelectedEffect( + this.actions$, + experimentAction.batchActions, + batchInvalidationTypes.filter((type) => type !== experimentAction.batchActions.batchDeleteCompleted.type) + ) + ); + finishBatch$ = createEffect(() => + batchFinishedEffect( + this.actions$, + this.store$.pipe(select(selectRootBatch)), + experimentAction.batchActions, + (state) => { + const counts = batchResultCounts(state); + const hasWarnings = counts.hasErrors || counts.uncertain; + const messageKey = hasWarnings + ? 'batch-delete.result.experiments' + : `batch-delete.success.experiments.${counts.deleted === 1 ? 'one' : 'other'}`; + const message = + this.translate.instant(messageKey, counts) + + (counts.uncertain ? ' ' + this.translate.instant('batch-delete.result.uncertain') : ''); + if (hasWarnings) this.notificationService.showWarning(message); + else this.notificationService.showSuccess(message); + return [ + experimentAction.actionGetExperiments({ fromStarting: true, batchRefresh: true }), + ...(counts.deleted || counts.absent + ? [ + experimentAction.actionFetchAllDecisionPoints(), + analysisActions.actionFetchMetrics(), + experimentAction.actionFetchAllExperimentNames(), + actionFetchListSegmentOptions(), + ] + : []), + ]; + } + ) + ); + + reconcileMissingSelections$ = createEffect(() => + eligibilityAbsenceEffect( + this.actions$, + this.store$.pipe(select(selectRootBatch)), + experimentAction.batchActions, + (count) => { + this.notificationService.showInfo(this.translate.instant('batch-delete.selection.absent', { count })); + return [experimentAction.actionGetExperiments({ fromStarting: true, batchRefresh: true })]; + } + ) + ); + constructor( private actions$: Actions, private store$: Store, @@ -54,59 +140,59 @@ export class ExperimentEffects { getPaginatedExperiment$ = createEffect(() => this.actions$.pipe( ofType(experimentAction.actionGetExperiments), - map((action) => action.fromStarting), - withLatestFrom( - this.store$.pipe(select(selectSkipExperiment)), - this.store$.pipe(select(selectTotalExperiment)), - this.store$.pipe(select(selectSearchKey)), - this.store$.pipe(select(selectSortKey)), - this.store$.pipe(select(selectSortAs)) + withLatestFrom(this.store$.pipe(select(selectExperimentState))), + filter( + ([action, state]) => + !isBatchBusy(state.rootBatch) && + (!state.rootBatch.listLoading || action.fromStarting) && + (action.fromStarting || state.totalExperiments === null || state.skipExperiment < state.totalExperiments) ), - filter(([fromStarting, skip, total]) => skip < total || total === null || fromStarting), - tap(() => { - this.store$.dispatch(experimentAction.actionSetIsLoadingExperiment({ isLoadingExperiment: true })); - }), - switchMap(([fromStarting, skip, _, searchKey, sortKey, sortAs]) => { - let searchString = null; - // As withLatestFrom does not support more than 5 arguments - // TODO: Find alternative - this.getSearchString$().subscribe((searchInput) => { - searchString = searchInput; - }); - let params: ExperimentPaginationParams = { - skip: fromStarting ? 0 : skip, + switchMap(([action, state]) => { + const fromStarting = !!action.fromStarting; + const params = { + skip: fromStarting ? 0 : state.skipExperiment, take: NUMBER_OF_EXPERIMENTS, + ...(state.sortKey ? { sortParams: { key: state.sortKey, sortAs: state.sortAs } } : {}), + searchParams: { key: state.searchKey, string: state.searchString || '' }, }; - if (sortKey) { - params = { - ...params, - sortParams: { - key: sortKey, - sortAs, - }, - }; - } - // Always send searchParams for experiments, even when searchString is blank - params = { - ...params, - searchParams: { - key: searchKey, - string: searchString || '', + return trackedListRequest( + this.store$.pipe(select(selectRootBatch)), + experimentAction.batchActions, + (event) => this.store$.dispatch(event), + fromStarting, + !!action.batchRefresh, + () => { + this.store$.dispatch(experimentAction.actionSetIsLoadingExperiment({ isLoadingExperiment: true })); + return this.experimentDataService.getAllExperiment(params, !!action.batchRefresh); }, - }; - return this.experimentDataService.getAllExperiment(params).pipe( - switchMap((data: any) => { - const experiments = data.nodes; - const experimentIds = experiments.map((experiment) => experiment.id); - const actions = fromStarting ? [experimentAction.actionSetSkipExperiment({ skipExperiment: 0 })] : []; - - return [ - ...actions, - experimentAction.actionGetExperimentsSuccess({ experiments, totalExperiments: data.total, fromStarting }), - experimentAction.actionFetchExperimentStats({ experimentIds }), - ]; - }), - catchError((error) => [experimentAction.actionGetExperimentsFailure(error)]) + (data: any, requestId) => [ + experimentAction.actionGetExperimentsSuccess({ + experiments: data.nodes, + totalExperiments: data.total, + fromStarting, + batchListRequestId: requestId, + }), + experimentAction.actionFetchExperimentStats({ experimentIds: data.nodes.map((row) => row.id) }), + ...(action.batchRefresh + ? [ + experimentAction.batchActions.refreshEligibility({ + requestId: newBatchRequestId(), + forConfirmation: false, + }), + ] + : []), + ], + () => [ + experimentAction.actionGetExperimentsFailure({ error: null }), + ...(action.batchRefresh + ? [ + experimentAction.batchActions.refreshEligibility({ + requestId: newBatchRequestId(), + forConfirmation: false, + }), + ] + : []), + ] ); }) ) diff --git a/packages/frontend/projects/upgrade/src/app/core/experiments/store/experiments.model.ts b/packages/frontend/projects/upgrade/src/app/core/experiments/store/experiments.model.ts index 7337200bb..22e061982 100644 --- a/packages/frontend/projects/upgrade/src/app/core/experiments/store/experiments.model.ts +++ b/packages/frontend/projects/upgrade/src/app/core/experiments/store/experiments.model.ts @@ -1,3 +1,4 @@ +import { RootBatchState } from '../../batch-actions/batch-actions.models'; import { AppState } from '../../core.module'; import { DetailsPageError } from '@shared-component-lib/common-page-error/common-page-error.model'; import { @@ -603,6 +604,7 @@ export const TS_CONFIGURABLE_OVERVIEW_PARAM_LABELS = { export const EXPERIMENT_ROOT_DISPLAYED_COLUMNS = Object.values(EXPERIMENT_ROOT_COLUMN_NAMES); export interface ExperimentState { + rootBatch: RootBatchState; // List page data - plain array preserves backend sort order experiments: ExperimentVM[]; isLoadingExperiment: boolean; diff --git a/packages/frontend/projects/upgrade/src/app/core/experiments/store/experiments.reducer.spec.ts b/packages/frontend/projects/upgrade/src/app/core/experiments/store/experiments.reducer.spec.ts index cd1014dbc..876983349 100644 --- a/packages/frontend/projects/upgrade/src/app/core/experiments/store/experiments.reducer.spec.ts +++ b/packages/frontend/projects/upgrade/src/app/core/experiments/store/experiments.reducer.spec.ts @@ -96,6 +96,7 @@ describe('ExperimentsReducer', () => { expect(newState).not.toBe(previousState); expect(newState).toEqual({ ...previousState, + rootBatch: { ...previousState.rootBatch, loadedIds: ['1'] }, experiments: [ { id: '1', @@ -501,6 +502,8 @@ describe('ExperimentsReducer', () => { expect(newState).not.toBe(previousState); expect(newState).toEqual({ ...previousState, + rootBatch: { ...previousState.rootBatch, removedIds: ['1'], revision: 1 }, + stats: {}, experiments: [], isLoadingExperimentDelete: false, }); diff --git a/packages/frontend/projects/upgrade/src/app/core/experiments/store/experiments.reducer.ts b/packages/frontend/projects/upgrade/src/app/core/experiments/store/experiments.reducer.ts index af7e6182d..127c5af46 100644 --- a/packages/frontend/projects/upgrade/src/app/core/experiments/store/experiments.reducer.ts +++ b/packages/frontend/projects/upgrade/src/app/core/experiments/store/experiments.reducer.ts @@ -1,8 +1,11 @@ +import { initialRootBatchState } from '../../batch-actions/batch-actions.models'; +import { withRootBatch } from '../../batch-actions/batch-actions.store'; import { ExperimentState, EXPERIMENT_SEARCH_KEY, SORT_AS_DIRECTION, EXPERIMENT_SORT_KEY } from './experiments.model'; import { createReducer, on, Action } from '@ngrx/store'; import * as experimentsAction from './experiments.actions'; export const initialState: ExperimentState = { + rootBatch: initialRootBatchState, // List page state experiments: [], isLoadingExperiment: false, @@ -528,6 +531,27 @@ const reducer = createReducer( })) ); +const batchReducer = withRootBatch(reducer, initialState, { + entity: 'experiments', + actions: experimentsAction.batchActions, + rowsKey: 'experiments', + skipKey: 'skipExperiment', + totalKey: 'totalExperiments', + queryTypes: [ + experimentsAction.actionSetSearchKey.type, + experimentsAction.actionSetSearchString.type, + experimentsAction.actionSetSearchParams.type, + experimentsAction.actionSetSortKey.type, + experimentsAction.actionSetSortingType.type, + ], + deletedId: (action) => + action.type === experimentsAction.actionDeleteExperimentSuccess.type + ? (action as ReturnType).experimentId + : undefined, + listSuccessType: experimentsAction.actionGetExperimentsSuccess.type, + responseRowsKey: 'experiments', +}); + export function experimentsReducer(state: ExperimentState | undefined, action: Action) { - return reducer(state, action); + return batchReducer(state, action); } diff --git a/packages/frontend/projects/upgrade/src/app/core/experiments/store/experiments.selectors.ts b/packages/frontend/projects/upgrade/src/app/core/experiments/store/experiments.selectors.ts index 49ccbe1f1..1742c5969 100644 --- a/packages/frontend/projects/upgrade/src/app/core/experiments/store/experiments.selectors.ts +++ b/packages/frontend/projects/upgrade/src/app/core/experiments/store/experiments.selectors.ts @@ -1,3 +1,4 @@ +import { selectionView } from '../../batch-actions/batch-actions.helpers'; import { createSelector, createFeatureSelector } from '@ngrx/store'; import { EXPERIMENT_SEARCH_KEY, @@ -587,3 +588,6 @@ export const selectExperimentMenuItems = createSelector( ]; } ); + +export const selectRootBatch = createSelector(selectExperimentState, (state) => state.rootBatch); +export const selectRootSelection = createSelector(selectRootBatch, (state) => selectionView(state, 'experiments')); diff --git a/packages/frontend/projects/upgrade/src/app/core/feature-flags/feature-flags.data.service.ts b/packages/frontend/projects/upgrade/src/app/core/feature-flags/feature-flags.data.service.ts index 22211f86f..f9f07d2ff 100644 --- a/packages/frontend/projects/upgrade/src/app/core/feature-flags/feature-flags.data.service.ts +++ b/packages/frontend/projects/upgrade/src/app/core/feature-flags/feature-flags.data.service.ts @@ -1,3 +1,5 @@ +import { BatchDeleteResult, DeletionEligibilityResult } from 'upgrade_types'; +import { batchHttpContext } from '../batch-actions/batch-actions.http'; import { Injectable } from '@angular/core'; import { HttpClient, HttpContext, HttpParams } from '@angular/common/http'; import { HANDLES_404_CONTEXTUALLY } from '../http-interceptors/http-context-tokens'; @@ -25,12 +27,29 @@ import { IImportFile, LIST_FILTER_MODE } from 'upgrade_types'; @Injectable() export class FeatureFlagsDataService { + checkDeletionEligibility(ids: string[]): Observable { + return this.http.post( + API_ENDPOINTS.flagsDeletionEligibility, + { ids }, + { context: batchHttpContext() } + ); + } + + batchDelete(ids: string[]): Observable { + return this.http.post(API_ENDPOINTS.flagsBatchDelete, { ids }, { context: batchHttpContext() }); + } + mockFeatureFlags: FeatureFlag[] = []; constructor(private http: HttpClient) {} - fetchFeatureFlagsPaginated(params: FeatureFlagsPaginationParams): Observable { + fetchFeatureFlagsPaginated( + params: FeatureFlagsPaginationParams, + batchRefresh = false + ): Observable { const url = API_ENDPOINTS.getPaginatedFlags; - return this.http.post(url, params); + return batchRefresh + ? this.http.post(url, params, { context: batchHttpContext() }) + : this.http.post(url, params); } fetchFeatureFlagById(id: string) { diff --git a/packages/frontend/projects/upgrade/src/app/core/feature-flags/feature-flags.service.ts b/packages/frontend/projects/upgrade/src/app/core/feature-flags/feature-flags.service.ts index d8141a798..340fbe18a 100644 --- a/packages/frontend/projects/upgrade/src/app/core/feature-flags/feature-flags.service.ts +++ b/packages/frontend/projects/upgrade/src/app/core/feature-flags/feature-flags.service.ts @@ -1,3 +1,6 @@ +import { createBatchFacade } from '../batch-actions/batch-actions.facade'; +import { batchActions } from './store/feature-flags.actions'; +import { selectRootBatch } from './store/feature-flags.selectors'; import { Injectable } from '@angular/core'; import { Store, select } from '@ngrx/store'; import { AppState } from '../core.state'; @@ -51,6 +54,8 @@ import { LocalStorageService } from '../local-storage/local-storage.service'; @Injectable() export class FeatureFlagsService { + readonly batch = createBatchFacade(this.store$, 'flags', batchActions, selectRootBatch, selectAllFeatureFlags); + constructor(private store$: Store, private localStorageService: LocalStorageService) {} currentUserEmailAddress$ = this.store$.pipe(select(selectCurrentUserEmail)); diff --git a/packages/frontend/projects/upgrade/src/app/core/feature-flags/store/feature-flags.actions.ts b/packages/frontend/projects/upgrade/src/app/core/feature-flags/store/feature-flags.actions.ts index f4ae3da4b..a2895dabc 100644 --- a/packages/frontend/projects/upgrade/src/app/core/feature-flags/store/feature-flags.actions.ts +++ b/packages/frontend/projects/upgrade/src/app/core/feature-flags/store/feature-flags.actions.ts @@ -1,3 +1,4 @@ +import { createBatchActions } from '../../batch-actions/batch-actions.actions'; import { createAction, props } from '@ngrx/store'; import { PAGE_ERROR_TYPE } from '@shared-component-lib/common-page-error/common-page-error.model'; import { @@ -15,12 +16,12 @@ import { AddPrivateSegmentListRequest, EditPrivateSegmentListRequest } from '../ export const actionFetchFeatureFlags = createAction( '[Feature Flags] Fetch Feature Flags Paginated', - props<{ fromStarting?: boolean }>() + props<{ fromStarting?: boolean; batchRefresh?: boolean }>() ); export const actionFetchFeatureFlagsSuccess = createAction( '[Feature Flags] Fetch Feature Flags Paginated Success', - props<{ flags: FeatureFlag[]; totalFlags: number }>() + props<{ batchListRequestId?: string; flags: FeatureFlag[]; totalFlags: number; fromStarting?: boolean }>() ); export const actionFetchFeatureFlagsFailure = createAction('[Feature Flags] Fetch Feature Flags Paginated Failure'); @@ -337,3 +338,5 @@ export const actionSetFeatureFlagTotalExposures = createAction( '[Feature Flags] Set Feature Flag Total Exposures', props<{ totalExposures: number | null }>() ); + +export const batchActions = createBatchActions('flags'); 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 018d8a3bc..1cad70c05 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 @@ -1,15 +1,30 @@ +import { + selectionFocusEffect, + eligibilityAbsenceEffect, + eligibilityEffect, + batchDeleteEffect, + reconcileBatchEffect, + batchFinishedEffect, + refreshSelectedEffect, + trackedListRequest, +} from '../../batch-actions/batch-actions.effects'; +import { isBatchBusy, newBatchRequestId } from '../../batch-actions/batch-actions.models'; +import { batchResultCounts } from '../../batch-actions/batch-actions.helpers'; +import { selectRootBatch, selectFeatureFlagsState } from './feature-flags.selectors'; +import { batchInvalidationTypes } from '../../batch-actions/batch-actions.invalidation'; +import { actionFetchListSegmentOptions } from '../../segments/store/segments.actions'; import { FeatureFlagsDataService } from '../feature-flags.data.service'; import { Actions, createEffect, ofType } from '@ngrx/effects'; import { Injectable } from '@angular/core'; import * as FeatureFlagsActions from './feature-flags.actions'; import { catchError, switchMap, mergeMap, map, filter, withLatestFrom, tap, first } from 'rxjs/operators'; -import { FeatureFlag, FeatureFlagsPaginationParams, NUMBER_OF_FLAGS } from './feature-flags.model'; +import { FeatureFlag, NUMBER_OF_FLAGS } from './feature-flags.model'; import { DATE_RANGE } from '../../experiments/store/experiments.model'; import { Router } from '@angular/router'; import { Store, select } from '@ngrx/store'; import { AppState, NotificationService } from '../../core.module'; import { TranslateService } from '@ngx-translate/core'; -import { selectSearchString, selectFeatureFlagPaginationParams } from './feature-flags.selectors'; +import { selectSearchString } from './feature-flags.selectors'; import { selectCurrentUser } from '../../auth/store/auth.selectors'; import { CommonExportHelpersService } from '../../../shared/services/common-export-helpers.service'; import { of } from 'rxjs'; @@ -20,6 +35,76 @@ import { isCanonicalEntityId, PAGE_ERROR_TYPE } from '@shared-component-lib/comm @Injectable() export class FeatureFlagsEffects { + focusBatchEligibility$ = createEffect(() => + selectionFocusEffect(this.store$.pipe(select(selectRootBatch)), FeatureFlagsActions.batchActions) + ); + batchEligibility$ = createEffect(() => + eligibilityEffect( + this.actions$, + this.store$.pipe(select(selectRootBatch)), + FeatureFlagsActions.batchActions, + this.featureFlagsDataService + ) + ); + batchDelete$ = createEffect(() => + batchDeleteEffect( + this.actions$, + this.store$.pipe(select(selectRootBatch)), + FeatureFlagsActions.batchActions, + this.featureFlagsDataService + ) + ); + reconcileBatch$ = createEffect(() => + reconcileBatchEffect( + this.actions$, + this.store$.pipe(select(selectRootBatch)), + FeatureFlagsActions.batchActions, + this.featureFlagsDataService + ) + ); + refreshBatchEligibility$ = createEffect(() => + refreshSelectedEffect( + this.actions$, + FeatureFlagsActions.batchActions, + batchInvalidationTypes.filter((type) => type !== FeatureFlagsActions.batchActions.batchDeleteCompleted.type) + ) + ); + finishBatch$ = createEffect(() => + batchFinishedEffect( + this.actions$, + this.store$.pipe(select(selectRootBatch)), + FeatureFlagsActions.batchActions, + (state) => { + const counts = batchResultCounts(state); + const hasWarnings = counts.hasErrors || counts.uncertain; + const messageKey = hasWarnings + ? 'batch-delete.result.flags' + : `batch-delete.success.flags.${counts.deleted === 1 ? 'one' : 'other'}`; + const message = + this.translate.instant(messageKey, counts) + + (counts.uncertain ? ' ' + this.translate.instant('batch-delete.result.uncertain') : ''); + if (hasWarnings) this.notificationService.showWarning(message); + else this.notificationService.showSuccess(message); + return [ + FeatureFlagsActions.actionFetchFeatureFlags({ fromStarting: true, batchRefresh: true }), + ...(counts.deleted || counts.absent ? [actionFetchListSegmentOptions()] : []), + ]; + } + ) + ); + + reconcileMissingSelections$ = createEffect(() => + eligibilityAbsenceEffect( + this.actions$, + this.store$.pipe(select(selectRootBatch)), + FeatureFlagsActions.batchActions, + (count) => { + this.notificationService.showInfo(this.translate.instant('batch-delete.selection.absent', { count })); + return [FeatureFlagsActions.actionFetchFeatureFlags({ fromStarting: true, batchRefresh: true })]; + } + ) + ); + constructor( private store$: Store, private actions$: Actions, @@ -34,51 +119,58 @@ export class FeatureFlagsEffects { fetchFeatureFlags$ = createEffect(() => this.actions$.pipe( ofType(FeatureFlagsActions.actionFetchFeatureFlags), - map((action) => action.fromStarting), - withLatestFrom(this.store$.pipe(select(selectFeatureFlagPaginationParams))), - filter(([fromStarting, pagination]) => { - return ( - !pagination.isAllFlagsFetched || - pagination.skip < pagination.total || - pagination.total === null || - fromStarting - ); - }), - tap(() => { - this.store$.dispatch(FeatureFlagsActions.actionSetIsLoadingFeatureFlags({ isLoadingFeatureFlags: true })); - }), - switchMap(([fromStarting, pagination]) => { - let params: FeatureFlagsPaginationParams = { - skip: fromStarting ? 0 : pagination.skip, + withLatestFrom(this.store$.pipe(select(selectFeatureFlagsState))), + filter( + ([action, state]) => + !isBatchBusy(state.rootBatch) && + (!state.rootBatch.listLoading || action.fromStarting) && + (action.fromStarting || state.totalFlags === null || state.skipFlags < state.totalFlags) + ), + switchMap(([action, state]) => { + const fromStarting = !!action.fromStarting; + const params = { + skip: fromStarting ? 0 : state.skipFlags, take: NUMBER_OF_FLAGS, + ...(state.sortKey ? { sortParams: { key: state.sortKey, sortAs: state.sortAs } } : {}), + ...(state.searchValue ? { searchParams: { key: state.searchKey, string: state.searchValue } } : {}), }; - if (pagination.sortKey) { - params = { - ...params, - sortParams: { - key: pagination.sortKey, - sortAs: pagination.sortAs, - }, - }; - } - if (pagination.searchString) { - params = { - ...params, - searchParams: { - key: pagination.searchKey, - string: pagination.searchString, - }, - }; - } - return this.featureFlagsDataService.fetchFeatureFlagsPaginated(params).pipe( - switchMap((data: any) => { - const actions = fromStarting ? [FeatureFlagsActions.actionSetSkipFlags({ skipFlags: 0 })] : []; - return [ - ...actions, - FeatureFlagsActions.actionFetchFeatureFlagsSuccess({ flags: data.nodes, totalFlags: data.total }), - ]; - }), - catchError(() => [FeatureFlagsActions.actionFetchFeatureFlagsFailure()]) + return trackedListRequest( + this.store$.pipe(select(selectRootBatch)), + FeatureFlagsActions.batchActions, + (event) => this.store$.dispatch(event), + fromStarting, + !!action.batchRefresh, + () => { + this.store$.dispatch(FeatureFlagsActions.actionSetIsLoadingFeatureFlags({ isLoadingFeatureFlags: true })); + return this.featureFlagsDataService.fetchFeatureFlagsPaginated(params, !!action.batchRefresh); + }, + (data: any, requestId) => [ + FeatureFlagsActions.actionFetchFeatureFlagsSuccess({ + flags: data.nodes, + totalFlags: data.total, + fromStarting, + batchListRequestId: requestId, + }), + ...(action.batchRefresh + ? [ + FeatureFlagsActions.batchActions.refreshEligibility({ + requestId: newBatchRequestId(), + forConfirmation: false, + }), + ] + : []), + ], + () => [ + FeatureFlagsActions.actionFetchFeatureFlagsFailure(), + ...(action.batchRefresh + ? [ + FeatureFlagsActions.batchActions.refreshEligibility({ + requestId: newBatchRequestId(), + forConfirmation: false, + }), + ] + : []), + ] ); }) ) diff --git a/packages/frontend/projects/upgrade/src/app/core/feature-flags/store/feature-flags.model.ts b/packages/frontend/projects/upgrade/src/app/core/feature-flags/store/feature-flags.model.ts index 2be1b480d..6a94ee015 100644 --- a/packages/frontend/projects/upgrade/src/app/core/feature-flags/store/feature-flags.model.ts +++ b/packages/frontend/projects/upgrade/src/app/core/feature-flags/store/feature-flags.model.ts @@ -1,3 +1,4 @@ +import { RootBatchState } from '../../batch-actions/batch-actions.models'; import { AppState } from '../../core.state'; import { DetailsPageError } from '@shared-component-lib/common-page-error/common-page-error.model'; import { FEATURE_FLAG_STATUS, FILTER_MODE, FLAG_SEARCH_KEY, FLAG_SORT_KEY, SORT_AS_DIRECTION } from 'upgrade_types'; @@ -190,6 +191,7 @@ export interface IExposureStatByDate { } export interface FeatureFlagState { + rootBatch: RootBatchState; // List page data - plain array preserves backend sort order featureFlags: FeatureFlag[]; isLoadingFeatureFlags: boolean; diff --git a/packages/frontend/projects/upgrade/src/app/core/feature-flags/store/feature-flags.reducer.ts b/packages/frontend/projects/upgrade/src/app/core/feature-flags/store/feature-flags.reducer.ts index e6f6e1884..15269adfd 100644 --- a/packages/frontend/projects/upgrade/src/app/core/feature-flags/store/feature-flags.reducer.ts +++ b/packages/frontend/projects/upgrade/src/app/core/feature-flags/store/feature-flags.reducer.ts @@ -1,9 +1,12 @@ +import { initialRootBatchState } from '../../batch-actions/batch-actions.models'; +import { withRootBatch } from '../../batch-actions/batch-actions.store'; import { createReducer, Action, on } from '@ngrx/store'; import { FeatureFlagState } from './feature-flags.model'; import * as FeatureFlagsActions from './feature-flags.actions'; import { FLAG_SEARCH_KEY, FLAG_SORT_KEY, SORT_AS_DIRECTION } from 'upgrade_types'; export const initialState: FeatureFlagState = { + rootBatch: initialRootBatchState, // List page state featureFlags: [], isLoadingFeatureFlags: false, @@ -52,10 +55,10 @@ const mergeSelectedFlagWithPartialResponse = ( const reducer = createReducer( initialState, - on(FeatureFlagsActions.actionFetchFeatureFlagsSuccess, (state, { flags, totalFlags }) => { + on(FeatureFlagsActions.actionFetchFeatureFlagsSuccess, (state, { flags, totalFlags, fromStarting }) => { // Replace entire array with backend data - preserves exact sort order const featureFlags = - state.skipFlags === 0 + fromStarting || state.skipFlags === 0 ? flags // First fetch - use backend data directly : [...state.featureFlags, ...flags]; // Pagination - append to existing @@ -63,7 +66,7 @@ const reducer = createReducer( ...state, featureFlags, totalFlags, - skipFlags: state.skipFlags + flags.length, + skipFlags: fromStarting ? flags.length : state.skipFlags + flags.length, isLoadingFeatureFlags: false, hasInitialFeatureFlagsDataLoaded: true, }; @@ -394,6 +397,27 @@ const reducer = createReducer( })) ); +const batchReducer = withRootBatch(reducer, initialState, { + entity: 'flags', + actions: FeatureFlagsActions.batchActions, + rowsKey: 'featureFlags', + skipKey: 'skipFlags', + totalKey: 'totalFlags', + queryTypes: [ + FeatureFlagsActions.actionSetSearchKey.type, + FeatureFlagsActions.actionSetSearchString.type, + FeatureFlagsActions.actionSetSortKey.type, + FeatureFlagsActions.actionSetSortingType.type, + ], + deletedId: (action) => { + if (action.type !== FeatureFlagsActions.actionDeleteFeatureFlagSuccess.type) return undefined; + const response = (action as ReturnType).flag; + return (Array.isArray(response) ? response[0] : response)?.id; + }, + listSuccessType: FeatureFlagsActions.actionFetchFeatureFlagsSuccess.type, + responseRowsKey: 'flags', +}); + export function featureFlagsReducer(state: FeatureFlagState | undefined, action: Action) { - return reducer(state, action); + return batchReducer(state, action); } diff --git a/packages/frontend/projects/upgrade/src/app/core/feature-flags/store/feature-flags.selectors.ts b/packages/frontend/projects/upgrade/src/app/core/feature-flags/store/feature-flags.selectors.ts index f296430bd..0ccbabe43 100644 --- a/packages/frontend/projects/upgrade/src/app/core/feature-flags/store/feature-flags.selectors.ts +++ b/packages/frontend/projects/upgrade/src/app/core/feature-flags/store/feature-flags.selectors.ts @@ -1,3 +1,4 @@ +import { selectionView } from '../../batch-actions/batch-actions.helpers'; import { createSelector, createFeatureSelector } from '@ngrx/store'; import { DetailsPageError } from '@shared-component-lib/common-page-error/common-page-error.model'; import { FeatureFlag, FeatureFlagState, ParticipantListTableRow } from './feature-flags.model'; @@ -247,3 +248,6 @@ export const selectWarningKeysForAllFlags = createSelector(selectFeatureFlagsSta }); return warningKeys; }); + +export const selectRootBatch = createSelector(selectFeatureFlagsState, (state) => state.rootBatch); +export const selectRootSelection = createSelector(selectRootBatch, (state) => selectionView(state, 'flags')); diff --git a/packages/frontend/projects/upgrade/src/app/core/http-interceptors/http-context-tokens.ts b/packages/frontend/projects/upgrade/src/app/core/http-interceptors/http-context-tokens.ts index 4ba196e66..57616cee8 100644 --- a/packages/frontend/projects/upgrade/src/app/core/http-interceptors/http-context-tokens.ts +++ b/packages/frontend/projects/upgrade/src/app/core/http-interceptors/http-context-tokens.ts @@ -5,3 +5,6 @@ import { HttpContextToken } from '@angular/common/http'; // Kept in its own file so data services can import it without pulling in the // interceptor's dependency graph (AuthService -> CoreModule), which is circular. export const HANDLES_404_CONTEXTUALLY = new HttpContextToken(() => false); + +// Batch results and metadata checks own their error UI; 401 must still log the user out. +export const HANDLES_BATCH_ERRORS_CONTEXTUALLY = new HttpContextToken(() => false); diff --git a/packages/frontend/projects/upgrade/src/app/core/http-interceptors/http-error.interceptor.ts b/packages/frontend/projects/upgrade/src/app/core/http-interceptors/http-error.interceptor.ts index a7f212029..ceb0fd3dc 100755 --- a/packages/frontend/projects/upgrade/src/app/core/http-interceptors/http-error.interceptor.ts +++ b/packages/frontend/projects/upgrade/src/app/core/http-interceptors/http-error.interceptor.ts @@ -6,7 +6,7 @@ import { catchError } from 'rxjs/operators'; import { ENV, Environment } from '../../../environments/environment-types'; import { AuthService } from '../auth/auth.service'; import { SERVER_ERROR } from 'upgrade_types'; -import { HANDLES_404_CONTEXTUALLY } from './http-context-tokens'; +import { HANDLES_404_CONTEXTUALLY, HANDLES_BATCH_ERRORS_CONTEXTUALLY } from './http-context-tokens'; @Injectable() export class HttpErrorInterceptor implements HttpInterceptor { @@ -34,7 +34,10 @@ export class HttpErrorInterceptor implements HttpInterceptor { if (err.status === 401) { // auto logout if 401 response returned from api this.authService.authLogout(); - } else if (!(err.status === 404 && request.context.get(HANDLES_404_CONTEXTUALLY))) { + } else if ( + !request.context?.get(HANDLES_BATCH_ERRORS_CONTEXTUALLY) && + !(err.status === 404 && request.context?.get(HANDLES_404_CONTEXTUALLY)) + ) { this.openPopup(err); } // re-throw to allow the error to be caught by the calling code diff --git a/packages/frontend/projects/upgrade/src/app/core/local-storage/local-storage.service.spec.ts b/packages/frontend/projects/upgrade/src/app/core/local-storage/local-storage.service.spec.ts index 1f6de6ce4..4bcba519f 100755 --- a/packages/frontend/projects/upgrade/src/app/core/local-storage/local-storage.service.spec.ts +++ b/packages/frontend/projects/upgrade/src/app/core/local-storage/local-storage.service.spec.ts @@ -1,3 +1,4 @@ +import { initialRootBatchState } from '../batch-actions/batch-actions.models'; import { ExperimentState, EXPERIMENT_SEARCH_KEY, @@ -21,6 +22,7 @@ describe('LocalStorageService', () => { describe('#loadInitialState', () => { const expectedStateWithFetchedValues: ExperimentState = { + rootBatch: initialRootBatchState, experiments: [], isLoadingExperiment: false, isLoadingExperimentDetailStats: false, @@ -50,6 +52,7 @@ describe('LocalStorageService', () => { detailsPageError: null, }; const expectedStateWithDefaults: ExperimentState = { + rootBatch: initialRootBatchState, experiments: [], isLoadingExperiment: false, isLoadingExperimentDetailStats: false, diff --git a/packages/frontend/projects/upgrade/src/app/core/local-storage/local-storage.service.ts b/packages/frontend/projects/upgrade/src/app/core/local-storage/local-storage.service.ts index 69a5e3dcf..2173b2c08 100755 --- a/packages/frontend/projects/upgrade/src/app/core/local-storage/local-storage.service.ts +++ b/packages/frontend/projects/upgrade/src/app/core/local-storage/local-storage.service.ts @@ -1,3 +1,4 @@ +import { initialRootBatchState } from '../batch-actions/batch-actions.models'; import { Injectable } from '@angular/core'; import { ExperimentLocalStorageKeys, @@ -33,6 +34,7 @@ export class LocalStorageService { // 1. Populate experiment state const experimentState: ExperimentState = { + rootBatch: initialRootBatchState, experiments: [], isLoadingExperiment: false, isLoadingExperimentDetailStats: false, @@ -63,6 +65,7 @@ export class LocalStorageService { }; const featureFlagState: FeatureFlagState = { + rootBatch: initialRootBatchState, featureFlags: [], selectedFlag: null, isLoadingUpsertFeatureFlag: false, @@ -87,6 +90,7 @@ export class LocalStorageService { }; const segmentState: SegmentState = { + rootBatch: initialRootBatchState, segments: [], isLoadingSegments: false, hasInitialSegmentsDataLoaded: false, diff --git a/packages/frontend/projects/upgrade/src/app/core/segments/segments.data.service.ts b/packages/frontend/projects/upgrade/src/app/core/segments/segments.data.service.ts index a5ab4ccce..faa0c5c0e 100644 --- a/packages/frontend/projects/upgrade/src/app/core/segments/segments.data.service.ts +++ b/packages/frontend/projects/upgrade/src/app/core/segments/segments.data.service.ts @@ -1,3 +1,5 @@ +import { BatchDeleteResult, DeletionEligibilityResult } from 'upgrade_types'; +import { batchHttpContext } from '../batch-actions/batch-actions.http'; import { Injectable } from '@angular/core'; import { AddPrivateSegmentListRequest, @@ -17,6 +19,22 @@ import { API_ENDPOINTS } from '../api-endpoints.constants'; @Injectable() export class SegmentsDataService { + checkDeletionEligibility(ids: string[]): Observable { + return this.http.post( + API_ENDPOINTS.segmentsDeletionEligibility, + { ids }, + { context: batchHttpContext() } + ); + } + + batchDelete(ids: string[]): Observable { + return this.http.post( + API_ENDPOINTS.segmentsBatchDelete, + { ids }, + { context: batchHttpContext() } + ); + } + constructor(private http: HttpClient) {} fetchAllSegments() { @@ -24,9 +42,11 @@ export class SegmentsDataService { return this.http.get(url); } - fetchSegmentsPaginated(params: SegmentsPaginationParams): Observable { + fetchSegmentsPaginated(params: SegmentsPaginationParams, batchRefresh = false): Observable { const url = API_ENDPOINTS.getPaginatedSegments; - return this.http.post(url, params); + return batchRefresh + ? this.http.post(url, params, { context: batchHttpContext() }) + : this.http.post(url, params); } fetchGlobalSegments() { diff --git a/packages/frontend/projects/upgrade/src/app/core/segments/segments.service.ts b/packages/frontend/projects/upgrade/src/app/core/segments/segments.service.ts index dd72007cd..6550c8aa0 100644 --- a/packages/frontend/projects/upgrade/src/app/core/segments/segments.service.ts +++ b/packages/frontend/projects/upgrade/src/app/core/segments/segments.service.ts @@ -1,3 +1,6 @@ +import { createBatchFacade } from '../batch-actions/batch-actions.facade'; +import { batchActions } from './store/segments.actions'; +import { selectRootBatch } from './store/segments.selectors'; import { Injectable } from '@angular/core'; import { Store, select } from '@ngrx/store'; import { AppState } from '../core.state'; @@ -52,6 +55,8 @@ import { actionFetchContextMetaData } from '../experiments/store/experiments.act @Injectable({ providedIn: 'root' }) export class SegmentsService { + readonly batch = createBatchFacade(this.store$, 'segments', batchActions, selectRootBatch, selectAllSegments); + constructor( private readonly store$: Store, private readonly segmentsDataService: SegmentsDataService, diff --git a/packages/frontend/projects/upgrade/src/app/core/segments/store/segments.actions.ts b/packages/frontend/projects/upgrade/src/app/core/segments/store/segments.actions.ts index 35c65648f..8dce7fd50 100644 --- a/packages/frontend/projects/upgrade/src/app/core/segments/store/segments.actions.ts +++ b/packages/frontend/projects/upgrade/src/app/core/segments/store/segments.actions.ts @@ -1,3 +1,4 @@ +import { createBatchActions } from '../../batch-actions/batch-actions.actions'; import { createAction, props } from '@ngrx/store'; import { PAGE_ERROR_TYPE } from '@shared-component-lib/common-page-error/common-page-error.model'; import { @@ -19,11 +20,15 @@ import { } from '../../../../../../../../types/src/Experiment/enums'; import { FeatureFlagSegmentListDetails } from '../../feature-flags/store/feature-flags.model'; -export const actionFetchSegments = createAction('[Segments] Segments', props<{ fromStarting?: boolean }>()); +export const actionFetchSegments = createAction( + '[Segments] Segments', + props<{ fromStarting?: boolean; batchRefresh?: boolean }>() +); export const actionFetchSegmentsSuccess = createAction( '[Segments] Fetch Segments Success', props<{ + batchListRequestId?: string; segments: Segment[]; totalSegments: number; experimentSegmentInclusion: experimentSegmentInclusionExclusionData[]; @@ -205,3 +210,5 @@ export const actionDeleteSegmentListFailure = createAction( '[Segments] Delete Segment List Failure', props<{ error: any }>() ); + +export const batchActions = createBatchActions('segments'); 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 858d82070..df814e0f0 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 @@ -1,3 +1,17 @@ +import { + selectionFocusEffect, + eligibilityAbsenceEffect, + eligibilityEffect, + batchDeleteEffect, + reconcileBatchEffect, + batchFinishedEffect, + refreshSelectedEffect, + trackedListRequest, +} from '../../batch-actions/batch-actions.effects'; +import { isBatchBusy, newBatchRequestId } from '../../batch-actions/batch-actions.models'; +import { batchResultCounts } from '../../batch-actions/batch-actions.helpers'; +import { selectRootBatch, selectSegmentsState } from './segments.selectors'; +import { batchInvalidationTypes } from '../../batch-actions/batch-actions.invalidation'; import { Injectable } from '@angular/core'; import { Router } from '@angular/router'; import { Actions, createEffect, ofType } from '@ngrx/effects'; @@ -7,19 +21,8 @@ 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 { - LIST_OPTION_TYPE, - NUMBER_OF_SEGMENTS, - Segment, - SegmentsPaginationParams, - UpsertSegmentType, -} from './segments.model'; -import { - selectAllSegments, - selectGlobalSegments, - selectSearchString, - selectSegmentPaginationParams, -} from './segments.selectors'; +import { LIST_OPTION_TYPE, NUMBER_OF_SEGMENTS, Segment, UpsertSegmentType } from './segments.model'; +import { selectAllSegments, selectGlobalSegments, selectSearchString } from './segments.selectors'; import JSZip from 'jszip'; import { of } from 'rxjs'; import { isCanonicalEntityId, PAGE_ERROR_TYPE } from '@shared-component-lib/common-page-error/common-page-error.model'; @@ -29,6 +32,76 @@ import { CommonModalEventsService } from '../../../shared/services/common-modal- @Injectable() export class SegmentsEffects { + focusBatchEligibility$ = createEffect(() => + selectionFocusEffect(this.store$.pipe(select(selectRootBatch)), SegmentsActions.batchActions) + ); + batchEligibility$ = createEffect(() => + eligibilityEffect( + this.actions$, + this.store$.pipe(select(selectRootBatch)), + SegmentsActions.batchActions, + this.segmentsDataService + ) + ); + batchDelete$ = createEffect(() => + batchDeleteEffect( + this.actions$, + this.store$.pipe(select(selectRootBatch)), + SegmentsActions.batchActions, + this.segmentsDataService + ) + ); + reconcileBatch$ = createEffect(() => + reconcileBatchEffect( + this.actions$, + this.store$.pipe(select(selectRootBatch)), + SegmentsActions.batchActions, + this.segmentsDataService + ) + ); + refreshBatchEligibility$ = createEffect(() => + refreshSelectedEffect( + this.actions$, + SegmentsActions.batchActions, + batchInvalidationTypes.filter((type) => type !== SegmentsActions.batchActions.batchDeleteCompleted.type) + ) + ); + finishBatch$ = createEffect(() => + batchFinishedEffect( + this.actions$, + this.store$.pipe(select(selectRootBatch)), + SegmentsActions.batchActions, + (state) => { + const counts = batchResultCounts(state); + const hasWarnings = counts.hasErrors || counts.uncertain; + const messageKey = hasWarnings + ? 'batch-delete.result.segments' + : `batch-delete.success.segments.${counts.deleted === 1 ? 'one' : 'other'}`; + const message = + this.translate.instant(messageKey, counts) + + (counts.uncertain ? ' ' + this.translate.instant('batch-delete.result.uncertain') : ''); + if (hasWarnings) this.notificationService.showWarning(message); + else this.notificationService.showSuccess(message); + return [ + SegmentsActions.actionFetchSegments({ fromStarting: true, batchRefresh: true }), + ...(counts.deleted || counts.absent ? [SegmentsActions.actionFetchListSegmentOptions()] : []), + ]; + } + ) + ); + + reconcileMissingSelections$ = createEffect(() => + eligibilityAbsenceEffect( + this.actions$, + this.store$.pipe(select(selectRootBatch)), + SegmentsActions.batchActions, + (count) => { + this.notificationService.showInfo(this.translate.instant('batch-delete.selection.absent', { count })); + return [SegmentsActions.actionFetchSegments({ fromStarting: true, batchRefresh: true })]; + } + ) + ); + constructor( private store$: Store, private actions$: Actions, @@ -43,55 +116,63 @@ export class SegmentsEffects { fetchSegmentsPaginated$ = createEffect(() => this.actions$.pipe( ofType(SegmentsActions.actionFetchSegments), - map((action) => action.fromStarting), - withLatestFrom(this.store$.pipe(select(selectSegmentPaginationParams))), - filter(([fromStarting, pagination]) => { - return ( - !pagination.areAllFetched || pagination.skip < pagination.total || pagination.total === null || fromStarting - ); - }), - tap(() => { - this.store$.dispatch(SegmentsActions.actionSetIsLoadingSegments({ isLoadingSegments: true })); - }), - switchMap(([fromStarting, pagination]) => { - let params: SegmentsPaginationParams = { - skip: fromStarting ? 0 : pagination.skip, + withLatestFrom(this.store$.pipe(select(selectSegmentsState))), + filter( + ([action, state]) => + !isBatchBusy(state.rootBatch) && + (!state.rootBatch.listLoading || action.fromStarting) && + (action.fromStarting || state.totalSegments === null || state.skipSegments < state.totalSegments) + ), + switchMap(([action, state]) => { + const fromStarting = !!action.fromStarting; + const params = { + skip: fromStarting ? 0 : state.skipSegments, take: NUMBER_OF_SEGMENTS, + ...(state.sortKey ? { sortParams: { key: state.sortKey, sortAs: state.sortAs } } : {}), + ...(state.searchString ? { searchParams: { key: state.searchKey, string: state.searchString } } : {}), }; - if (pagination.sortKey) { - params = { - ...params, - sortParams: { - key: pagination.sortKey, - sortAs: pagination.sortAs, - }, - }; - } - if (pagination.searchString) { - params = { - ...params, - searchParams: { - key: pagination.searchKey, - string: pagination.searchString, - }, - }; - } - return this.segmentsDataService.fetchSegmentsPaginated(params).pipe( - switchMap((data: any) => { - return [ - SegmentsActions.actionFetchSegmentsSuccess({ - segments: data.nodes.segmentsData, - totalSegments: data.total, - experimentSegmentInclusion: data.nodes.experimentSegmentInclusionData, - experimentSegmentExclusion: data.nodes.experimentSegmentExclusionData, - featureFlagSegmentInclusion: data.nodes.featureFlagSegmentInclusionData, - featureFlagSegmentExclusion: data.nodes.featureFlagSegmentExclusionData, - allParentSegments: data.nodes.allParentSegments, - fromStarting, - }), - ]; - }), - catchError(() => [SegmentsActions.actionFetchSegmentsFailure()]) + return trackedListRequest( + this.store$.pipe(select(selectRootBatch)), + SegmentsActions.batchActions, + (event) => this.store$.dispatch(event), + fromStarting, + !!action.batchRefresh, + () => { + this.store$.dispatch(SegmentsActions.actionSetIsLoadingSegments({ isLoadingSegments: true })); + return this.segmentsDataService.fetchSegmentsPaginated(params, !!action.batchRefresh); + }, + (data: any, requestId) => [ + SegmentsActions.actionFetchSegmentsSuccess({ + segments: data.nodes.segmentsData, + totalSegments: data.total, + experimentSegmentInclusion: data.nodes.experimentSegmentInclusionData, + experimentSegmentExclusion: data.nodes.experimentSegmentExclusionData, + featureFlagSegmentInclusion: data.nodes.featureFlagSegmentInclusionData, + featureFlagSegmentExclusion: data.nodes.featureFlagSegmentExclusionData, + allParentSegments: data.nodes.allParentSegments, + fromStarting, + batchListRequestId: requestId, + }), + ...(action.batchRefresh + ? [ + SegmentsActions.batchActions.refreshEligibility({ + requestId: newBatchRequestId(), + forConfirmation: false, + }), + ] + : []), + ], + () => [ + SegmentsActions.actionFetchSegmentsFailure(), + ...(action.batchRefresh + ? [ + SegmentsActions.batchActions.refreshEligibility({ + requestId: newBatchRequestId(), + forConfirmation: false, + }), + ] + : []), + ] ); }) ) 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 d9914e090..5316d2bdc 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 @@ -1,3 +1,4 @@ +import { RootBatchState } from '../../batch-actions/batch-actions.models'; import { AppState } from '../../core.state'; import { DetailsPageError } from '@shared-component-lib/common-page-error/common-page-error.model'; import { SEGMENT_TYPE, SEGMENT_STATUS, SEGMENT_SEARCH_KEY, SORT_AS_DIRECTION, SEGMENT_SORT_KEY } from 'upgrade_types'; @@ -232,6 +233,7 @@ export enum SEGMENT_LIST_ACTIONS { } export interface SegmentState { + rootBatch: RootBatchState; // List page data - plain array preserves backend sort order segments: Segment[]; isLoadingSegments: boolean; diff --git a/packages/frontend/projects/upgrade/src/app/core/segments/store/segments.reducer.ts b/packages/frontend/projects/upgrade/src/app/core/segments/store/segments.reducer.ts index 38b26a64e..91b03b5b3 100644 --- a/packages/frontend/projects/upgrade/src/app/core/segments/store/segments.reducer.ts +++ b/packages/frontend/projects/upgrade/src/app/core/segments/store/segments.reducer.ts @@ -1,3 +1,5 @@ +import { initialRootBatchState } from '../../batch-actions/batch-actions.models'; +import { withRootBatch } from '../../batch-actions/batch-actions.store'; import { createReducer, Action, on } from '@ngrx/store'; import { SegmentState, GlobalSegmentState } from './segments.model'; import * as SegmentsActions from './segments.actions'; @@ -8,6 +10,7 @@ import { } from '../../../../../../../../types/src/Experiment/enums'; export const initialState: SegmentState = { + rootBatch: initialRootBatchState, // List page data - plain array preserves backend sort order segments: [], isLoadingSegments: false, @@ -187,8 +190,29 @@ const reducer = createReducer( })) ); +const batchReducer = withRootBatch(reducer, initialState, { + entity: 'segments', + actions: SegmentsActions.batchActions, + rowsKey: 'segments', + skipKey: 'skipSegments', + totalKey: 'totalSegments', + queryTypes: [ + SegmentsActions.actionSetSearchKey.type, + SegmentsActions.actionSetSearchString.type, + SegmentsActions.actionSetSortKey.type, + SegmentsActions.actionSetSortingType.type, + ], + deletedId: (action) => { + if (action.type !== SegmentsActions.actionDeleteSegmentSuccess.type) return undefined; + const response = (action as ReturnType).segment; + return (Array.isArray(response) ? response[0] : response)?.id; + }, + listSuccessType: SegmentsActions.actionFetchSegmentsSuccess.type, + responseRowsKey: 'segments', +}); + export function segmentsReducer(state: SegmentState | undefined, action: Action) { - return reducer(state, action); + return batchReducer(state, action); } export const initalGlobalState: GlobalSegmentState = { diff --git a/packages/frontend/projects/upgrade/src/app/core/segments/store/segments.selectors.ts b/packages/frontend/projects/upgrade/src/app/core/segments/store/segments.selectors.ts index bb3ef9892..58363831f 100644 --- a/packages/frontend/projects/upgrade/src/app/core/segments/store/segments.selectors.ts +++ b/packages/frontend/projects/upgrade/src/app/core/segments/store/segments.selectors.ts @@ -1,3 +1,4 @@ +import { selectionView } from '../../batch-actions/batch-actions.helpers'; import { createSelector, createFeatureSelector } from '@ngrx/store'; import { DetailsPageError } from '@shared-component-lib/common-page-error/common-page-error.model'; import { @@ -328,3 +329,6 @@ function processParentSegments(segmentData: Segment[], segmentId: string, result } }); } + +export const selectRootBatch = createSelector(selectSegmentsState, (state) => state.rootBatch); +export const selectRootSelection = createSelector(selectRootBatch, (state) => selectionView(state, 'segments')); diff --git a/packages/frontend/projects/upgrade/src/assets/i18n/en.json b/packages/frontend/projects/upgrade/src/assets/i18n/en.json index f2bba4c7f..50c3bb3e9 100644 --- a/packages/frontend/projects/upgrade/src/assets/i18n/en.json +++ b/packages/frontend/projects/upgrade/src/assets/i18n/en.json @@ -1,4 +1,16 @@ { + "batch-delete.selection.absent": "{{count}} selected items are already absent and were removed from the selection.", + "batch-delete.success.experiments.one": "1 experiment deleted.", + "batch-delete.success.experiments.other": "{{deleted}} experiments deleted.", + "batch-delete.success.flags.one": "1 feature flag deleted.", + "batch-delete.success.flags.other": "{{deleted}} feature flags deleted.", + "batch-delete.success.segments.one": "1 segment deleted.", + "batch-delete.success.segments.other": "{{deleted}} segments deleted.", + "batch-delete.result.experiments": "Experiments: {{deleted}} deleted. {{absent}} already absent. {{failed}} could not be deleted. {{notAttempted}} not attempted. {{remaining}} remain selected.", + "batch-delete.result.flags": "Feature flags: {{deleted}} deleted. {{absent}} already absent. {{failed}} could not be deleted. {{notAttempted}} not attempted. {{remaining}} remain selected.", + "batch-delete.result.segments": "Segments: {{deleted}} deleted. {{absent}} already absent. {{failed}} could not be deleted. {{notAttempted}} not attempted. {{remaining}} remain selected.", + "batch-delete.result.uncertain": "Some outcomes could not be confirmed. No deletion was retried automatically.", + "global.app.name": "UpGrade", "global.app.description": "Open Source A/B Testing Platform for Education Software", "global.experiment.title": "Experiments", diff --git a/packages/frontend/projects/upgrade/src/environments/environment-types.ts b/packages/frontend/projects/upgrade/src/environments/environment-types.ts index f4f5964f5..b895b17ee 100644 --- a/packages/frontend/projects/upgrade/src/environments/environment-types.ts +++ b/packages/frontend/projects/upgrade/src/environments/environment-types.ts @@ -3,6 +3,12 @@ import { InjectionToken } from '@angular/core'; export const ENV = new InjectionToken('env.token'); export interface APIEndpoints { + experimentsDeletionEligibility: string; + experimentsBatchDelete: string; + flagsDeletionEligibility: string; + flagsBatchDelete: string; + segmentsDeletionEligibility: string; + segmentsBatchDelete: string; getAllExperiments: string; createNewExperiments: string; validateExperiment: string; From 40edc0c02bb2138c8811dbbb9b421c9764f5a9d8 Mon Sep 17 00:00:00 2001 From: Zack Lee Date: Thu, 10 Sep 2026 19:05:06 -0400 Subject: [PATCH 05/61] feat: add batch deletion UI using cached selection state --- packages/frontend/jest.config.js | 4 +- .../batch-actions/batch-actions.actions.ts | 11 +- .../batch-actions/batch-actions.effects.ts | 82 +---- .../batch-actions/batch-actions.facade.ts | 7 +- .../batch-actions/batch-actions.helpers.ts | 1 - .../batch-actions.integration.spec.ts | 115 +++--- .../batch-actions.invalidation.ts | 47 --- .../batch-actions/batch-actions.models.ts | 9 - .../batch-actions/batch-actions.reducer.ts | 69 +--- .../experiments/store/experiments.effects.ts | 57 +-- .../store/feature-flags.effects.ts | 57 +-- .../core/segments/store/segments.effects.ts | 57 +-- .../dashboard/batch-delete-ui.spec.ts | 346 ++++++++++++++++++ ...ent-root-section-card-table.component.html | 99 ++++- ...ent-root-section-card-table.component.scss | 2 +- ...iment-root-section-card-table.component.ts | 3 + ...xperiment-root-section-card.component.html | 20 +- .../experiment-root-section-card.component.ts | 16 +- ...lag-root-section-card-table.component.html | 95 ++++- ...lag-root-section-card-table.component.scss | 2 +- ...-flag-root-section-card-table.component.ts | 3 + ...ture-flag-root-section-card.component.html | 18 +- ...eature-flag-root-section-card.component.ts | 22 +- ...ent-root-section-card-table.component.html | 99 ++++- ...ent-root-section-card-table.component.scss | 2 +- ...gment-root-section-card-table.component.ts | 3 + .../segment-root-section-card.component.html | 18 +- .../segment-root-section-card.component.ts | 16 +- .../common-batch-delete-modal.component.html | 62 ++++ .../common-batch-delete-modal.component.scss | 26 ++ ...ommon-batch-delete-modal.component.spec.ts | 218 +++++++++++ .../common-batch-delete-modal.component.ts | 101 +++++ ...common-batch-selection-status.component.ts | 37 ++ .../common-modal/common-modal.component.html | 14 +- .../common-modal/common-modal.component.ts | 3 + ...section-card-action-buttons.component.html | 18 +- .../root-batch-actions.directive.ts | 85 +++++ .../shared/services/common-dialog.service.ts | 15 + .../projects/upgrade/src/assets/i18n/en.json | 63 +++- .../frontend/projects/upgrade/src/styles.scss | 2 + .../upgrade/src/styles/_batch-selection.scss | 56 +++ 41 files changed, 1460 insertions(+), 520 deletions(-) delete mode 100644 packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.invalidation.ts create mode 100644 packages/frontend/projects/upgrade/src/app/features/dashboard/batch-delete-ui.spec.ts create mode 100644 packages/frontend/projects/upgrade/src/app/shared-standalone-component-lib/components/common-batch-delete-modal/common-batch-delete-modal.component.html create mode 100644 packages/frontend/projects/upgrade/src/app/shared-standalone-component-lib/components/common-batch-delete-modal/common-batch-delete-modal.component.scss create mode 100644 packages/frontend/projects/upgrade/src/app/shared-standalone-component-lib/components/common-batch-delete-modal/common-batch-delete-modal.component.spec.ts create mode 100644 packages/frontend/projects/upgrade/src/app/shared-standalone-component-lib/components/common-batch-delete-modal/common-batch-delete-modal.component.ts create mode 100644 packages/frontend/projects/upgrade/src/app/shared-standalone-component-lib/components/common-batch-selection-status/common-batch-selection-status.component.ts create mode 100644 packages/frontend/projects/upgrade/src/app/shared/directives/root-batch-actions.directive.ts create mode 100644 packages/frontend/projects/upgrade/src/styles/_batch-selection.scss diff --git a/packages/frontend/jest.config.js b/packages/frontend/jest.config.js index 2901cd302..fa9f2900d 100644 --- a/packages/frontend/jest.config.js +++ b/packages/frontend/jest.config.js @@ -36,8 +36,8 @@ module.exports = { '/dist/', '/e2e/', '/src/environments/', - // Dashboard specs are excluded except the routing spec, which guards subtle redirect behavior - '/src/app/features/dashboard/(?!dashboard-routing\\.spec)', + // Keep the routing and batch root UI integration specs discoverable. + '/src/app/features/dashboard/(?!dashboard-routing\\.spec|batch-delete-ui\\.spec)', '/src/app/shared/', ], diff --git a/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.actions.ts b/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.actions.ts index 9e5e37bad..2573e150f 100644 --- a/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.actions.ts +++ b/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.actions.ts @@ -9,16 +9,7 @@ export function createBatchActions(source: string) { toggleHeader: createAction(`${prefix} Toggle Header`, props<{ items: RootSelectionItem[] }>()), clearSelection: createAction(`${prefix} Clear Selection`), confirmedRemoved: createAction(`${prefix} Confirmed Removed`, props<{ ids: string[] }>()), - invalidateEligibility: createAction(`${prefix} Invalidate Eligibility`), - refreshEligibility: createAction( - `${prefix} Refresh Eligibility`, - props<{ requestId: string; forConfirmation: boolean }>() - ), - eligibilitySucceeded: createAction( - `${prefix} Eligibility Succeeded`, - props<{ requestId: string; revision: number; result: DeletionEligibilityResult }>() - ), - eligibilityFailed: createAction(`${prefix} Eligibility Failed`, props<{ requestId: string; revision: number }>()), + prepareConfirmation: createAction(`${prefix} Prepare Confirmation`, props<{ operationId: string }>()), dismissConfirmation: createAction(`${prefix} Dismiss Confirmation`), batchDeleteRequested: createAction(`${prefix} Delete Requested`, props<{ snapshot: BatchDeleteSnapshot }>()), batchDeleteCompleted: createAction( diff --git a/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.effects.ts b/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.effects.ts index 9a98e0829..a0d5e3dfa 100644 --- a/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.effects.ts +++ b/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.effects.ts @@ -1,7 +1,6 @@ import { Action } from '@ngrx/store'; -import { EMPTY, Observable, concat, defer, fromEvent, merge, of } from 'rxjs'; +import { Observable, concat, defer, of } from 'rxjs'; import { - auditTime, catchError, distinctUntilChanged, exhaustMap, @@ -15,7 +14,7 @@ import { } from 'rxjs/operators'; import { BatchDeleteResult, DeletionEligibilityResult } from 'upgrade_types'; import { RootBatchActions } from './batch-actions.actions'; -import { RootBatchState, isBatchBusy, newBatchRequestId } from './batch-actions.models'; +import { RootBatchState, newBatchRequestId } from './batch-actions.models'; import { validateBatchResponse, validateEligibilityResponse } from './batch-actions.helpers'; export interface BatchDataSource { @@ -23,34 +22,6 @@ export interface BatchDataSource { batchDelete(ids: string[]): Observable; } -export function eligibilityEffect( - events: Observable, - state$: Observable, - actions: RootBatchActions, - data: BatchDataSource -) { - return events.pipe( - filter((action) => action.type === actions.refreshEligibility.type), - withLatestFrom(state$), - filter( - ([action, state]) => - (action as ReturnType).requestId === state.eligibility.requestId - ), - switchMap(([, state]) => { - const { requestId, revision } = state.eligibility; - const ids = Object.keys(state.selectedById); - return defer(() => data.checkDeletionEligibility(ids)).pipe( - throwIfEmpty(), - map((result) => - actions.eligibilitySucceeded({ requestId, revision, result: validateEligibilityResponse(result, ids) }) - ), - catchError(() => of(actions.eligibilityFailed({ requestId, revision }))), - takeUntil(state$.pipe(filter((current) => current.eligibility.requestId !== requestId))) - ); - }) - ); -} - export function batchDeleteEffect( events: Observable, state$: Observable, @@ -145,55 +116,6 @@ export function batchFinishedEffect( ); } -/** Invalidation changes the revision before the read is dispatched. Coalescing is enforced in the reducer. */ -export function refreshSelectedEffect( - events: Observable, - actions: RootBatchActions, - invalidatingTypes: string[] -) { - return events.pipe( - filter((action) => invalidatingTypes.includes(action.type)), - switchMap(() => [ - actions.invalidateEligibility(), - actions.refreshEligibility({ requestId: newBatchRequestId(), forConfirmation: false }), - ]) - ); -} - -export function selectionFocusEffect(state$: Observable, actions: RootBatchActions) { - return defer(() => - typeof window === 'undefined' - ? EMPTY - : merge( - fromEvent(window, 'focus'), - fromEvent(document, 'visibilitychange').pipe(filter(() => document.visibilityState === 'visible')) - ) - ).pipe( - auditTime(100), - withLatestFrom(state$), - filter(([, state]) => !!Object.keys(state.selectedById).length && !isBatchBusy(state)), - map(() => actions.refreshEligibility({ requestId: newBatchRequestId(), forConfirmation: false })) - ); -} - -export function eligibilityAbsenceEffect( - events: Observable, - state$: Observable, - actions: RootBatchActions, - finish: (count: number) => Action[] -) { - return events.pipe( - filter((action) => action.type === actions.eligibilitySucceeded.type), - withLatestFrom(state$), - filter( - ([action, state]) => - (action as ReturnType).requestId === state.eligibility.requestId && - !!state.eligibility.absentIds?.length - ), - switchMap(([, state]) => finish(state.eligibility.absentIds.length)) - ); -} - /** Read current state at subscription time, after the list-start action reaches the reducer. */ export function trackedListRequest( state$: Observable, diff --git a/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.facade.ts b/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.facade.ts index a0e8016cf..aaaa154b8 100644 --- a/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.facade.ts +++ b/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.facade.ts @@ -24,10 +24,7 @@ export function createBatchFacade( .pipe(select(selectRows), take(1)) .subscribe((rows) => store.dispatch(actions.toggleHeader({ items: rows.map(selectionItem) }))), clear: () => store.dispatch(actions.clearSelection()), - refresh: () => - store.dispatch(actions.refreshEligibility({ requestId: newBatchRequestId(), forConfirmation: false })), - prepareConfirmation: () => - store.dispatch(actions.refreshEligibility({ requestId: newBatchRequestId(), forConfirmation: true })), + prepareConfirmation: () => store.dispatch(actions.prepareConfirmation({ operationId: newBatchRequestId() })), dismissConfirmation: () => store.dispatch(actions.dismissConfirmation()), submit: (operationId: string) => state$.pipe(take(1)).subscribe((state) => { @@ -36,3 +33,5 @@ export function createBatchFacade( }), }; } + +export type BatchFacade = ReturnType; diff --git a/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.helpers.ts b/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.helpers.ts index 568206424..e1c427daf 100644 --- a/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.helpers.ts +++ b/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.helpers.ts @@ -56,7 +56,6 @@ export function selectionView(state: RootBatchState, entity: BatchDeleteEntity) canToggleHeader: !isBatchBusy(state) && (items.length > 0 || (!state.listLoading && loaded.size > 0)), canRequestConfirmation: items.length > 0 && !reasons.length && !isBatchBusy(state), reasonCode: reasons[0], - checking: state.eligibility.status === 'checking', busy: isBatchBusy(state), }; } diff --git a/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.integration.spec.ts b/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.integration.spec.ts index 9fad5b55b..841203de7 100644 --- a/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.integration.spec.ts +++ b/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.integration.spec.ts @@ -1,5 +1,6 @@ import { TestBed, fakeAsync, tick } from '@angular/core/testing'; -import { Actions } from '@ngrx/effects'; +import { TranslateModule, TranslateService } from '@ngx-translate/core'; +import { Actions, getEffectsMetadata } from '@ngrx/effects'; import { Action, ScannedActionsSubject, Store, StoreModule } from '@ngrx/store'; import { Subject, Subscription, of, throwError } from 'rxjs'; import { @@ -95,7 +96,7 @@ describe.each(fixtures)('$entity batch store/effects integration', (config) => { const selectRows = (count = rows.length) => rows.slice(0, count).forEach((row) => store.dispatch(actions.toggleRow({ item: selectionItem(row) }))); const prepare = () => { - store.dispatch(actions.refreshEligibility({ requestId: newBatchRequestId(), forConfirmation: true })); + store.dispatch(actions.prepareConfirmation({ operationId: newBatchRequestId() })); expect(batch().confirmation).not.toBeNull(); return batch().confirmation; }; @@ -103,6 +104,7 @@ describe.each(fixtures)('$entity batch store/effects integration', (config) => { beforeEach(() => { TestBed.configureTestingModule({ imports: [ + TranslateModule.forRoot(), StoreModule.forRoot({ experiments: experimentsReducer, featureFlags: featureFlagsReducer, @@ -124,7 +126,9 @@ describe.each(fixtures)('$entity batch store/effects integration', (config) => { }; router = { navigate: jest.fn() }; notifications = { showSuccess: jest.fn(), showWarning: jest.fn(), showError: jest.fn(), showInfo: jest.fn() }; - const translate = { instant: jest.fn((key) => key) }; + const translate = TestBed.inject(TranslateService); + translate.setTranslation('en', jest.requireActual('../../../assets/i18n/en.json')); + translate.use('en'); effects = config.entity === 'experiments' ? new ExperimentEffects( @@ -159,16 +163,9 @@ describe.each(fixtures)('$entity batch store/effects integration', (config) => { translate as any, {} as any ); - for (const field of [ - 'focusBatchEligibility$', - 'batchEligibility$', - 'batchDelete$', - 'reconcileBatch$', - 'finishBatch$', - 'refreshBatchEligibility$', - 'reconcileMissingSelections$', - config.fetchEffect, - ]) { + // Include every batch effect so reintroducing a focus/refresh effect cannot escape the no-read assertions. + const batchEffects = Object.keys(getEffectsMetadata(effects)).filter((field) => /batch/i.test(field)); + for (const field of [...batchEffects, config.fetchEffect]) { subscriptions.add(effects[field].subscribe((action) => store.dispatch(action))); } store.dispatch(actionSetUserInfo({ user: { email: 'review@example.com', role: UserRole.ADMIN } })); @@ -213,69 +210,75 @@ describe.each(fixtures)('$entity batch store/effects integration', (config) => { expect(currentRows()).toHaveLength(1); }); - it('coalesces focus checks with confirmation and ignores an obsolete response after deselection', () => { + it('prepares immediately from retained metadata and invalidates the snapshot after deselection', () => { selectRows(); - const check = new Subject(); - data.checkDeletionEligibility.mockReturnValue(check); - store.dispatch(actions.refreshEligibility({ requestId: newBatchRequestId(), forConfirmation: false })); - store.dispatch(actions.refreshEligibility({ requestId: newBatchRequestId(), forConfirmation: true })); - expect(data.checkDeletionEligibility).toHaveBeenCalledTimes(1); - check.next(eligibility(rows.map((row) => row.id))); - expect(batch().confirmation.items).toHaveLength(3); - store.dispatch(actions.refreshEligibility({ requestId: newBatchRequestId(), forConfirmation: true })); + const snapshot = prepare(); + expect(snapshot.items).toHaveLength(3); + store.dispatch(actions.prepareConfirmation({ operationId: newBatchRequestId() })); + expect(batch().confirmation).toBe(snapshot); store.dispatch(actions.toggleRow({ item: selectionItem(rows[0]) })); - check.next(eligibility(rows.map((row) => row.id))); expect(batch().confirmation).toBeNull(); expect(batch().selectedById[rows[0].id]).toBeUndefined(); + expect(data.checkDeletionEligibility).not.toHaveBeenCalled(); + store.dispatch(actions.batchDeleteRequested({ snapshot })); + expect(data.batchDelete).not.toHaveBeenCalled(); }); - it('coalesces window-focus notifications and skips checks during deletion', fakeAsync(() => { + it('does not fetch eligibility on app/tab focus, opening confirmation, or a related mutation', fakeAsync(() => { window.dispatchEvent(new Event('focus')); tick(100); expect(data.checkDeletionEligibility).not.toHaveBeenCalled(); selectRows(); window.dispatchEvent(new Event('focus')); - window.dispatchEvent(new Event('focus')); + document.dispatchEvent(new Event('visibilitychange')); + store.dispatch(flagActions.actionDeleteFeatureFlagSuccess({ flag: { id: 'other' } as any })); tick(100); - expect(data.checkDeletionEligibility).toHaveBeenCalledTimes(1); + expect(data.checkDeletionEligibility).not.toHaveBeenCalled(); const snapshot = prepare(); store.dispatch(actions.batchDeleteRequested({ snapshot })); window.dispatchEvent(new Event('focus')); + document.dispatchEvent(new Event('visibilitychange')); tick(100); - expect(data.checkDeletionEligibility).toHaveBeenCalledTimes(2); + expect(data.checkDeletionEligibility).not.toHaveBeenCalled(); + expect(data.batchDelete).toHaveBeenCalledTimes(1); })); - it('keeps selections on a failed metadata read and blocks confirmation for any hidden restriction', () => { + it('uses updated page data to block a hidden restriction without an eligibility request', () => { selectRows(); - data.checkDeletionEligibility.mockReturnValueOnce(throwError(() => new Error('offline'))); - store.dispatch(actions.refreshEligibility({ requestId: newBatchRequestId(), forConfirmation: true })); - expect(batch().eligibility.status).toBe('failed'); - expect(Object.keys(batch().selectedById)).toHaveLength(3); - const result = eligibility(rows.map((row) => row.id)); - result.items[2] = { ...result.items[2], canDelete: false, reasonCode: DeletionReasonCode.MISSING_PERMISSION }; - data.checkDeletionEligibility.mockReturnValueOnce(of(result)); - store.dispatch(actions.refreshEligibility({ requestId: newBatchRequestId(), forConfirmation: true })); + const blocked = { + ...rows[2], + state: config.entity === 'experiments' ? EXPERIMENT_STATE.DRAFT : undefined, + status: config.entity === 'segments' ? SEGMENT_STATUS.USED : FEATURE_FLAG_STATUS.ENABLED, + }; + data[config.fetchMethod].mockReturnValueOnce(of(page([rows[0], rows[1], blocked]))); + store.dispatch(config.fetch({ fromStarting: true })); + data[config.fetchMethod].mockReturnValueOnce(of(page([rows[0]]))); + store.dispatch(config.fetch({ fromStarting: true })); + store.dispatch(actions.prepareConfirmation({ operationId: newBatchRequestId() })); expect(batch().confirmation).toBeNull(); - expect(batch().eligibility.allDeletable).toBe(false); + expect(Object.keys(batch().selectedById)).toHaveLength(3); + expect(selectionView(batch(), config.entity).canRequestConfirmation).toBe(false); + expect(data.checkDeletionEligibility).not.toHaveBeenCalled(); expect(data.batchDelete).not.toHaveBeenCalled(); }); - it('removes only authoritative absences from an eligibility check and reports them separately', () => { + it('retains cached selections until the delete response establishes an absence', () => { selectRows(); - data.checkDeletionEligibility.mockReturnValueOnce( - of( - eligibility( - rows.map((row) => row.id), - [rows[1].id] - ) - ) - ); - store.dispatch(actions.refreshEligibility({ requestId: newBatchRequestId(), forConfirmation: true })); + const snapshot = prepare(); + expect(snapshot.items).toHaveLength(3); + expect(data.checkDeletionEligibility).not.toHaveBeenCalled(); + store.dispatch(actions.batchDeleteRequested({ snapshot })); + response.next({ + phase: 'rejected', + results: rows.map(({ id }, index) => ({ id, outcome: index === 1 ? 'not_found' : 'not_attempted' })), + }); expect(batch().selectedById[rows[1].id]).toBeUndefined(); expect(currentRows().some((row) => row.id === rows[1].id)).toBe(false); - expect(notifications.showInfo).toHaveBeenCalledTimes(1); + expect(batchResultCounts(batch())).toMatchObject({ deleted: 0, absent: 1, remaining: 2 }); + expect(notifications.showWarning).toHaveBeenCalledTimes(1); expect(data[config.fetchMethod]).toHaveBeenLastCalledWith(expect.objectContaining({ skip: 0 }), true); expect(batch().confirmation).toBeNull(); + expect(data.checkDeletionEligibility).not.toHaveBeenCalled(); }); it('freezes the request, rejects duplicate submits, and preserves failures while refreshing the current query', () => { @@ -304,7 +307,7 @@ describe.each(fixtures)('$entity batch store/effects integration', (config) => { expect(router.navigate).not.toHaveBeenCalled(); }); - it('keeps the displayed confirmation snapshot when background rows and focus refreshes arrive', fakeAsync(() => { + it('keeps the displayed confirmation snapshot when updated rows arrive or focus returns', fakeAsync(() => { selectRows(2); const snapshot = prepare(); data[config.fetchMethod].mockReturnValueOnce(of(page([{ ...rows[0], name: 'renamed' }]))); @@ -314,20 +317,20 @@ describe.each(fixtures)('$entity batch store/effects integration', (config) => { expect(batch().confirmation).toBe(snapshot); expect(snapshot.items.map(({ name }) => name)).toEqual(['a', 'b']); expect(batch().selectedById[rows[0].id].name).toBe('renamed'); - expect(data.checkDeletionEligibility).toHaveBeenCalledTimes(1); + expect(data.checkDeletionEligibility).not.toHaveBeenCalled(); store.dispatch(actions.batchDeleteRequested({ snapshot })); expect(data.batchDelete).toHaveBeenCalledWith([rows[0].id, rows[1].id]); })); - it('does not restore rows from a list response that started before deletion', () => { - selectRows(1); + it.each([1, 3])('reports %i deletions once and ignores a list response from before deletion', (count) => { + selectRows(count); const snapshot = prepare(); const pending = new Subject(); data[config.fetchMethod].mockReturnValueOnce(pending); store.dispatch(config.fetch({ fromStarting: true })); const oldRequestId = batch().listRequestId; store.dispatch(actions.batchDeleteRequested({ snapshot })); - response.next({ phase: 'executed', results: [{ id: rows[0].id, outcome: 'deleted' }] }); + response.next({ phase: 'executed', results: rows.slice(0, count).map(({ id }) => ({ id, outcome: 'deleted' })) }); pending.next(page()); const oldSuccess = events.find( (action: any) => action.batchListRequestId && action.type.includes('Success') @@ -335,7 +338,8 @@ describe.each(fixtures)('$entity batch store/effects integration', (config) => { store.dispatch({ ...oldSuccess, batchListRequestId: oldRequestId }); expect(currentRows().some((row) => row.id === rows[0].id)).toBe(false); expect(notifications.showSuccess).toHaveBeenCalledTimes(1); - expect(notifications.showSuccess).toHaveBeenCalledWith(`batch-delete.success.${config.entity}.one`); + const noun = { experiments: 'experiment', flags: 'feature flag', segments: 'segment' }[config.entity]; + expect(notifications.showSuccess).toHaveBeenCalledWith(`${count} ${noun}${count === 1 ? '' : 's'} deleted.`); }); it('reconciles a lost response with one bulk read, counts absence separately, and never retries deletion', () => { @@ -354,7 +358,8 @@ describe.each(fixtures)('$entity batch store/effects integration', (config) => { ) ); expect(batchResultCounts(batch())).toMatchObject({ deleted: 0, absent: 1, remaining: 2, uncertain: true }); - expect(data.checkDeletionEligibility.mock.calls[1][0]).toEqual(rows.map((row) => row.id)); + expect(data.checkDeletionEligibility).toHaveBeenCalledTimes(1); + expect(data.checkDeletionEligibility).toHaveBeenCalledWith(rows.map((row) => row.id)); expect(data.batchDelete).toHaveBeenCalledTimes(1); expect(notifications.showWarning).toHaveBeenCalledTimes(1); }); diff --git a/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.invalidation.ts b/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.invalidation.ts deleted file mode 100644 index da5831910..000000000 --- a/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.invalidation.ts +++ /dev/null @@ -1,47 +0,0 @@ -import * as auth from '../auth/store/auth.actions'; -import * as experiments from '../experiments/store/experiments.actions'; - -import * as flags from '../feature-flags/store/feature-flags.actions'; - -import * as segments from '../segments/store/segments.actions'; - -// These writes can change a selected name, state, or segment usage through an owner/list. -export const batchInvalidationTypes: string[] = [ - experiments.batchActions.batchDeleteCompleted.type, - flags.batchActions.batchDeleteCompleted.type, - segments.batchActions.batchDeleteCompleted.type, - auth.actionSetUserInfo.type, - auth.actionSetUserInfoSuccess.type, - auth.actionLoginSuccess.type, - experiments.actionUpsertExperimentSuccess.type, - experiments.actionDeleteExperimentSuccess.type, - experiments.actionUpdateExperimentStateSuccess.type, - experiments.actionUpdateExperimentFilterModeSuccess.type, - experiments.actionImportExperimentSuccess.type, - experiments.actionAddExperimentInclusionListSuccess.type, - experiments.actionUpdateExperimentInclusionListSuccess.type, - experiments.actionDeleteExperimentInclusionListSuccess.type, - experiments.actionAddExperimentExclusionListSuccess.type, - experiments.actionUpdateExperimentExclusionListSuccess.type, - experiments.actionDeleteExperimentExclusionListSuccess.type, - flags.actionAddFeatureFlagSuccess.type, - flags.actionDeleteFeatureFlagSuccess.type, - flags.actionUpdateFeatureFlagSuccess.type, - flags.actionUpdateFeatureFlagStatusSuccess.type, - flags.actionUpdateFilterModeSuccess.type, - flags.actionAddFeatureFlagInclusionListSuccess.type, - flags.actionUpdateFeatureFlagInclusionListSuccess.type, - flags.actionUpdateFeatureFlagInclusionListStatusSuccess.type, - flags.actionDeleteFeatureFlagInclusionListSuccess.type, - flags.actionAddFeatureFlagExclusionListSuccess.type, - flags.actionUpdateFeatureFlagExclusionListSuccess.type, - flags.actionUpdateFeatureFlagExclusionListStatusSuccess.type, - flags.actionDeleteFeatureFlagExclusionListSuccess.type, - segments.actionUpsertSegmentSuccess.type, - segments.actionAddSegmentSuccess.type, - segments.actionUpdateSegmentSuccess.type, - segments.actionDeleteSegmentSuccess.type, - segments.actionAddSegmentListSuccess.type, - segments.actionUpdateSegmentListSuccess.type, - segments.actionDeleteSegmentListSuccess.type, -]; diff --git a/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.models.ts b/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.models.ts index 5881392b1..4f92b1784 100644 --- a/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.models.ts +++ b/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.models.ts @@ -22,14 +22,6 @@ export interface RootBatchState { listLoading: boolean; listRefreshFailed: boolean; removedIds: string[]; - eligibility: { - status: 'idle' | 'checking' | 'ready' | 'failed'; - requestId?: string; - revision?: number; - forConfirmation?: boolean; - allDeletable?: boolean; - absentIds?: string[]; - }; confirmation: BatchDeleteSnapshot | null; operation: { snapshot: BatchDeleteSnapshot; @@ -51,7 +43,6 @@ export const initialRootBatchState: RootBatchState = { listLoading: false, listRefreshFailed: false, removedIds: [], - eligibility: { status: 'idle' }, confirmation: null, operation: null, }; diff --git a/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.reducer.ts b/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.reducer.ts index 03f0eb94e..8bfadeb65 100644 --- a/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.reducer.ts +++ b/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.reducer.ts @@ -13,7 +13,7 @@ function matches( } export function invalidateSelection(state: RootBatchState): RootBatchState { - return { ...state, revision: state.revision + 1, eligibility: { status: 'idle' }, confirmation: null }; + return { ...state, revision: state.revision + 1, confirmation: null }; } function removeConfirmed(state: RootBatchState, ids: string[]): RootBatchState { @@ -94,7 +94,6 @@ export function reduceRootBatch( return action.requestId === state.listRequestId ? { ...state, listLoading: false, listRefreshFailed: action.batchRefresh } : state; - if (matches(action, actions.invalidateEligibility)) return invalidateSelection(state); if (matches(action, actions.confirmedRemoved)) return removeConfirmed(state, action.ids); if ( matches(action, actions.clearSelection) || @@ -126,67 +125,17 @@ export function reduceRootBatch( } return { ...invalidateSelection(state), selectedById }; } - if (matches(action, actions.refreshEligibility)) { - if (isBatchBusy(state) || !Object.keys(state.selectedById).length) return state; - if (state.confirmation && !action.forConfirmation) return state; - if (state.eligibility.status === 'checking' && state.eligibility.revision === state.revision) { - return { - ...state, - eligibility: { - ...state.eligibility, - forConfirmation: state.eligibility.forConfirmation || action.forConfirmation, - }, - }; - } + if (matches(action, actions.prepareConfirmation)) { + const selection = selectionView(state, entity); + if (state.confirmation || !selection.canRequestConfirmation) return state; + // Use retained row metadata, including hidden selections. The delete endpoint validates current eligibility. return { ...state, - confirmation: null, - eligibility: { - status: 'checking', - requestId: action.requestId, + confirmation: { + operationId: action.operationId, revision: state.revision, - forConfirmation: action.forConfirmation, - }, - }; - } - if (matches(action, actions.eligibilitySucceeded) || matches(action, actions.eligibilityFailed)) { - if (action.requestId !== state.eligibility.requestId || action.revision !== state.revision) return state; - if (matches(action, actions.eligibilityFailed)) - return { ...state, confirmation: null, eligibility: { status: 'failed' } }; - const selectedById = { ...state.selectedById }; - for (const item of action.result.items) { - if (selectedById[item.id]) { - const { id, name, stateOrStatus, segmentType, availability, reasonCode } = item; - selectedById[id] = { - id, - name: name || selectedById[id].name, - stateOrStatus, - segmentType, - availability, - reasonCode, - }; - } - } - const allDeletable = action.result.allDeletable && hasBatchDeletePermission(state.role, entity); - const confirmation = - state.eligibility.forConfirmation && allDeletable - ? { - operationId: action.requestId, - revision: state.revision, - items: Object.values(selectedById).map((item) => ({ ...item })), - notShownCount: selectionView(state, entity).notShownCount, - } - : null; - const absentIds = action.result.items.filter((item) => item.availability === 'not_found').map((item) => item.id); - return { - ...removeConfirmed({ ...state, selectedById }, absentIds), - confirmation: absentIds.length ? null : confirmation, - eligibility: { - status: 'ready', - requestId: action.requestId, - revision: action.revision, - allDeletable: allDeletable && !absentIds.length, - absentIds, + items: selection.items.map((item) => ({ ...item })), + notShownCount: selection.notShownCount, }, }; } 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 a1ec0650c..96f074e7d 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 @@ -1,17 +1,12 @@ import { - selectionFocusEffect, - eligibilityAbsenceEffect, - eligibilityEffect, batchDeleteEffect, reconcileBatchEffect, batchFinishedEffect, - refreshSelectedEffect, trackedListRequest, } from '../../batch-actions/batch-actions.effects'; -import { isBatchBusy, newBatchRequestId } from '../../batch-actions/batch-actions.models'; +import { isBatchBusy } from '../../batch-actions/batch-actions.models'; import { batchResultCounts } from '../../batch-actions/batch-actions.helpers'; import { selectRootBatch, selectExperimentState } from './experiments.selectors'; -import { batchInvalidationTypes } from '../../batch-actions/batch-actions.invalidation'; import { actionFetchListSegmentOptions } from '../../segments/store/segments.actions'; import { Inject, Injectable } from '@angular/core'; import { Actions, createEffect, ofType } from '@ngrx/effects'; @@ -48,17 +43,6 @@ import { LIST_FILTER_MODE } from 'upgrade_types'; import { LIST_OPTION_TYPE } from '../../segments/store/segments.model'; @Injectable() export class ExperimentEffects { - focusBatchEligibility$ = createEffect(() => - selectionFocusEffect(this.store$.pipe(select(selectRootBatch)), experimentAction.batchActions) - ); - batchEligibility$ = createEffect(() => - eligibilityEffect( - this.actions$, - this.store$.pipe(select(selectRootBatch)), - experimentAction.batchActions, - this.experimentDataService - ) - ); batchDelete$ = createEffect(() => batchDeleteEffect( this.actions$, @@ -75,13 +59,6 @@ export class ExperimentEffects { this.experimentDataService ) ); - refreshBatchEligibility$ = createEffect(() => - refreshSelectedEffect( - this.actions$, - experimentAction.batchActions, - batchInvalidationTypes.filter((type) => type !== experimentAction.batchActions.batchDeleteCompleted.type) - ) - ); finishBatch$ = createEffect(() => batchFinishedEffect( this.actions$, @@ -113,18 +90,6 @@ export class ExperimentEffects { ) ); - reconcileMissingSelections$ = createEffect(() => - eligibilityAbsenceEffect( - this.actions$, - this.store$.pipe(select(selectRootBatch)), - experimentAction.batchActions, - (count) => { - this.notificationService.showInfo(this.translate.instant('batch-delete.selection.absent', { count })); - return [experimentAction.actionGetExperiments({ fromStarting: true, batchRefresh: true })]; - } - ) - ); - constructor( private actions$: Actions, private store$: Store, @@ -173,26 +138,8 @@ export class ExperimentEffects { batchListRequestId: requestId, }), experimentAction.actionFetchExperimentStats({ experimentIds: data.nodes.map((row) => row.id) }), - ...(action.batchRefresh - ? [ - experimentAction.batchActions.refreshEligibility({ - requestId: newBatchRequestId(), - forConfirmation: false, - }), - ] - : []), ], - () => [ - experimentAction.actionGetExperimentsFailure({ error: null }), - ...(action.batchRefresh - ? [ - experimentAction.batchActions.refreshEligibility({ - requestId: newBatchRequestId(), - forConfirmation: false, - }), - ] - : []), - ] + () => [experimentAction.actionGetExperimentsFailure({ error: null })] ); }) ) 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 1cad70c05..7d247488c 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 @@ -1,17 +1,12 @@ import { - selectionFocusEffect, - eligibilityAbsenceEffect, - eligibilityEffect, batchDeleteEffect, reconcileBatchEffect, batchFinishedEffect, - refreshSelectedEffect, trackedListRequest, } from '../../batch-actions/batch-actions.effects'; -import { isBatchBusy, newBatchRequestId } from '../../batch-actions/batch-actions.models'; +import { isBatchBusy } from '../../batch-actions/batch-actions.models'; import { batchResultCounts } from '../../batch-actions/batch-actions.helpers'; import { selectRootBatch, selectFeatureFlagsState } from './feature-flags.selectors'; -import { batchInvalidationTypes } from '../../batch-actions/batch-actions.invalidation'; import { actionFetchListSegmentOptions } from '../../segments/store/segments.actions'; import { FeatureFlagsDataService } from '../feature-flags.data.service'; import { Actions, createEffect, ofType } from '@ngrx/effects'; @@ -35,17 +30,6 @@ import { isCanonicalEntityId, PAGE_ERROR_TYPE } from '@shared-component-lib/comm @Injectable() export class FeatureFlagsEffects { - focusBatchEligibility$ = createEffect(() => - selectionFocusEffect(this.store$.pipe(select(selectRootBatch)), FeatureFlagsActions.batchActions) - ); - batchEligibility$ = createEffect(() => - eligibilityEffect( - this.actions$, - this.store$.pipe(select(selectRootBatch)), - FeatureFlagsActions.batchActions, - this.featureFlagsDataService - ) - ); batchDelete$ = createEffect(() => batchDeleteEffect( this.actions$, @@ -62,13 +46,6 @@ export class FeatureFlagsEffects { this.featureFlagsDataService ) ); - refreshBatchEligibility$ = createEffect(() => - refreshSelectedEffect( - this.actions$, - FeatureFlagsActions.batchActions, - batchInvalidationTypes.filter((type) => type !== FeatureFlagsActions.batchActions.batchDeleteCompleted.type) - ) - ); finishBatch$ = createEffect(() => batchFinishedEffect( this.actions$, @@ -93,18 +70,6 @@ export class FeatureFlagsEffects { ) ); - reconcileMissingSelections$ = createEffect(() => - eligibilityAbsenceEffect( - this.actions$, - this.store$.pipe(select(selectRootBatch)), - FeatureFlagsActions.batchActions, - (count) => { - this.notificationService.showInfo(this.translate.instant('batch-delete.selection.absent', { count })); - return [FeatureFlagsActions.actionFetchFeatureFlags({ fromStarting: true, batchRefresh: true })]; - } - ) - ); - constructor( private store$: Store, private actions$: Actions, @@ -151,26 +116,8 @@ export class FeatureFlagsEffects { fromStarting, batchListRequestId: requestId, }), - ...(action.batchRefresh - ? [ - FeatureFlagsActions.batchActions.refreshEligibility({ - requestId: newBatchRequestId(), - forConfirmation: false, - }), - ] - : []), ], - () => [ - FeatureFlagsActions.actionFetchFeatureFlagsFailure(), - ...(action.batchRefresh - ? [ - FeatureFlagsActions.batchActions.refreshEligibility({ - requestId: newBatchRequestId(), - forConfirmation: false, - }), - ] - : []), - ] + () => [FeatureFlagsActions.actionFetchFeatureFlagsFailure()] ); }) ) 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 df814e0f0..c01345bdf 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 @@ -1,17 +1,12 @@ import { - selectionFocusEffect, - eligibilityAbsenceEffect, - eligibilityEffect, batchDeleteEffect, reconcileBatchEffect, batchFinishedEffect, - refreshSelectedEffect, trackedListRequest, } from '../../batch-actions/batch-actions.effects'; -import { isBatchBusy, newBatchRequestId } from '../../batch-actions/batch-actions.models'; +import { isBatchBusy } from '../../batch-actions/batch-actions.models'; import { batchResultCounts } from '../../batch-actions/batch-actions.helpers'; import { selectRootBatch, selectSegmentsState } from './segments.selectors'; -import { batchInvalidationTypes } from '../../batch-actions/batch-actions.invalidation'; import { Injectable } from '@angular/core'; import { Router } from '@angular/router'; import { Actions, createEffect, ofType } from '@ngrx/effects'; @@ -32,17 +27,6 @@ import { CommonModalEventsService } from '../../../shared/services/common-modal- @Injectable() export class SegmentsEffects { - focusBatchEligibility$ = createEffect(() => - selectionFocusEffect(this.store$.pipe(select(selectRootBatch)), SegmentsActions.batchActions) - ); - batchEligibility$ = createEffect(() => - eligibilityEffect( - this.actions$, - this.store$.pipe(select(selectRootBatch)), - SegmentsActions.batchActions, - this.segmentsDataService - ) - ); batchDelete$ = createEffect(() => batchDeleteEffect( this.actions$, @@ -59,13 +43,6 @@ export class SegmentsEffects { this.segmentsDataService ) ); - refreshBatchEligibility$ = createEffect(() => - refreshSelectedEffect( - this.actions$, - SegmentsActions.batchActions, - batchInvalidationTypes.filter((type) => type !== SegmentsActions.batchActions.batchDeleteCompleted.type) - ) - ); finishBatch$ = createEffect(() => batchFinishedEffect( this.actions$, @@ -90,18 +67,6 @@ export class SegmentsEffects { ) ); - reconcileMissingSelections$ = createEffect(() => - eligibilityAbsenceEffect( - this.actions$, - this.store$.pipe(select(selectRootBatch)), - SegmentsActions.batchActions, - (count) => { - this.notificationService.showInfo(this.translate.instant('batch-delete.selection.absent', { count })); - return [SegmentsActions.actionFetchSegments({ fromStarting: true, batchRefresh: true })]; - } - ) - ); - constructor( private store$: Store, private actions$: Actions, @@ -153,26 +118,8 @@ export class SegmentsEffects { fromStarting, batchListRequestId: requestId, }), - ...(action.batchRefresh - ? [ - SegmentsActions.batchActions.refreshEligibility({ - requestId: newBatchRequestId(), - forConfirmation: false, - }), - ] - : []), ], - () => [ - SegmentsActions.actionFetchSegmentsFailure(), - ...(action.batchRefresh - ? [ - SegmentsActions.batchActions.refreshEligibility({ - requestId: newBatchRequestId(), - forConfirmation: false, - }), - ] - : []), - ] + () => [SegmentsActions.actionFetchSegmentsFailure()] ); }) ) diff --git a/packages/frontend/projects/upgrade/src/app/features/dashboard/batch-delete-ui.spec.ts b/packages/frontend/projects/upgrade/src/app/features/dashboard/batch-delete-ui.spec.ts new file mode 100644 index 000000000..892342b2e --- /dev/null +++ b/packages/frontend/projects/upgrade/src/app/features/dashboard/batch-delete-ui.spec.ts @@ -0,0 +1,346 @@ +import { ComponentFixture, TestBed, fakeAsync, tick } from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { provideRouter } from '@angular/router'; +import { OverlayContainer } from '@angular/cdk/overlay'; +import { Store, StoreModule } from '@ngrx/store'; +import { TranslateModule, TranslateService } from '@ngx-translate/core'; +import { BehaviorSubject, Subject, Subscription, of } from 'rxjs'; +import { EXPERIMENT_STATE, FEATURE_FLAG_STATUS, SEGMENT_STATUS, SEGMENT_TYPE, UserRole } from 'upgrade_types'; +import { ExperimentRootSectionCardComponent } from './experiments/pages/experiment-root-page/experiment-root-page-content/experiment-root-section-card/experiment-root-section-card.component'; +import { FeatureFlagRootSectionCardComponent } from './feature-flags/pages/feature-flag-root-page/feature-flag-root-page-content/feature-flag-root-section-card/feature-flag-root-section-card.component'; +import { SegmentRootSectionCardComponent } from './segments/pages/segment-root-page/segment-root-page-content/segment-root-section-card/segment-root-section-card.component'; +import { ExperimentService } from '../../core/experiments/experiments.service'; +import { FeatureFlagsService } from '../../core/feature-flags/feature-flags.service'; +import { SegmentsService } from '../../core/segments/segments.service'; +import { AuthService } from '../../core/auth/auth.service'; +import { StratificationFactorsService } from '../../core/stratification-factors/stratification-factors.service'; +import { DialogService } from '../../shared/services/common-dialog.service'; +import { createBatchFacade } from '../../core/batch-actions/batch-actions.facade'; +import { RootBatchActionsDirective } from '../../shared/directives/root-batch-actions.directive'; +import { actionSetUserInfo } from '../../core/auth/store/auth.actions'; +import { experimentsReducer } from '../../core/experiments/store/experiments.reducer'; +import { featureFlagsReducer } from '../../core/feature-flags/store/feature-flags.reducer'; +import { segmentsReducer } from '../../core/segments/store/segments.reducer'; +import * as experiments from '../../core/experiments/store/experiments.actions'; +import * as flags from '../../core/feature-flags/store/feature-flags.actions'; +import * as segments from '../../core/segments/store/segments.actions'; + +const translations = jest.requireActual('../../../assets/i18n/en.json'); +const cases = [ + { + entity: 'experiments', + key: 'experiments', + component: ExperimentRootSectionCardComponent, + token: ExperimentService, + actions: experiments, + }, + { + entity: 'flags', + key: 'featureFlags', + component: FeatureFlagRootSectionCardComponent, + token: FeatureFlagsService, + actions: flags, + }, + { + entity: 'segments', + key: 'segments', + component: SegmentRootSectionCardComponent, + token: SegmentsService, + actions: segments, + }, +] as const; + +describe.each(cases)('$entity root batch UI', (config) => { + let fixture: ComponentFixture; + let store: Store; + let state: any; + let subscription: Subscription; + let service: any; + let dialogs: any; + let closed: Subject; + let overlay: OverlayContainer; + let permissions$: BehaviorSubject; + const actions = config.actions.batchActions; + const rows = ['Alpha', 'Beta'].map((name, index) => ({ + id: `11111111-2222-4333-8444-${String(index + 1).padStart(12, '0')}`, + name, + description: 'Description', + context: ['test'], + tags: [], + state: config.entity === 'experiments' ? EXPERIMENT_STATE.INACTIVE : undefined, + status: config.entity === 'segments' ? SEGMENT_STATUS.UNUSED : FEATURE_FLAG_STATUS.DISABLED, + type: SEGMENT_TYPE.PUBLIC, + })); + const batch = () => state[config.key].rootBatch; + const checkboxes = (): HTMLInputElement[] => [...fixture.nativeElement.querySelectorAll('input[type=checkbox]')]; + function load(items = rows) { + const action = + config.entity === 'experiments' + ? experiments.actionGetExperimentsSuccess({ + experiments: items as any, + totalExperiments: items.length, + fromStarting: true, + }) + : config.entity === 'flags' + ? flags.actionFetchFeatureFlagsSuccess({ flags: items as any, totalFlags: items.length, fromStarting: true }) + : segments.actionFetchSegmentsSuccess({ + segments: items as any, + totalSegments: items.length, + fromStarting: true, + experimentSegmentInclusion: [], + experimentSegmentExclusion: [], + featureFlagSegmentInclusion: [], + featureFlagSegmentExclusion: [], + allParentSegments: [], + }); + store.dispatch(action); + } + function selectFirst() { + checkboxes()[1].click(); + fixture.detectChanges(); + } + function openMenu() { + fixture.nativeElement.querySelector('.section-card-menu-trigger').click(); + fixture.detectChanges(); + tick(); + } + beforeEach(async () => { + permissions$ = new BehaviorSubject({ + experiments: { create: true }, + featureFlags: { create: true }, + segments: { create: true }, + }); + global.IntersectionObserver = jest.fn(() => ({ observe: jest.fn(), disconnect: jest.fn() })) as any; + await TestBed.configureTestingModule({ + imports: [ + config.component, + NoopAnimationsModule, + TranslateModule.forRoot(), + StoreModule.forRoot({ + experiments: experimentsReducer, + featureFlags: featureFlagsReducer, + segments: segmentsReducer, + }), + ], + providers: [ + provideRouter([]), + { provide: config.token, useFactory: () => service }, + { + provide: AuthService, + useValue: { + userPermissions$: permissions$, + }, + }, + { provide: StratificationFactorsService, useValue: { fetchStratificationFactors: jest.fn() } }, + { provide: DialogService, useFactory: () => dialogs }, + ], + }).compileComponents(); + store = TestBed.inject(Store); + subscription = store.subscribe((value) => (state = value)); + store.dispatch(actionSetUserInfo({ user: { email: 'test@example.com', role: UserRole.ADMIN } })); + load(); + const batchFacade = createBatchFacade( + store, + config.entity, + actions, + (s) => s[config.key].rootBatch, + (s) => s[config.key][config.key] + ); + const rows$ = store.select((s) => s[config.key][config.key]); + service = { + batch: batchFacade, + experiments$: rows$, + featureFlags$: rows$, + selectAllSegments$: rows$, + isLoadingExperiment$: of(false), + isLoadingFeatureFlags$: of(false), + isLoadingSegments$: of(false), + haveInitialExperimentsLoaded: () => of(true), + isInitialFeatureFlagsLoading$: of(true), + isInitialSegmentsLoading: () => of(true), + selectSearchString$: of(''), + searchString$: of(''), + selectSearchKey$: of('name'), + searchKey$: of('name'), + searchParams$: of({}), + selectRootTableState$: of({}), + selectExperimentSortKey$: of('name'), + selectExperimentSortAs$: of('ASC'), + sortKey$: of('name'), + sortAs$: of('ASC'), + selectSegmentSortKey$: of('name'), + selectSegmentSortAs$: of('ASC'), + warningKeysForAllExperiments$: of({}), + warningKeysForAllFlags$: of({}), + loadExperiments: jest.fn(), + fetchFeatureFlags: jest.fn(), + fetchSegmentsPaginated: jest.fn(), + fetchAllExperimentNames: jest.fn(), + setSearchParams: jest.fn(), + setSearchString: jest.fn(), + setSearchKey: jest.fn(), + setSortingType: jest.fn(), + setSortKey: jest.fn(), + }; + closed = new Subject(); + dialogs = { openBatchDeleteModal: jest.fn(() => ({ afterClosed: () => closed, close: jest.fn() })) }; + overlay = TestBed.inject(OverlayContainer); + const translate = TestBed.inject(TranslateService); + translate.setTranslation('en', translations); + translate.use('en'); + fixture = TestBed.createComponent(config.component as any); + fixture.detectChanges(); + fixture.detectChanges(); + }); + afterEach(() => { + fixture.destroy(); + subscription.unsubscribe(); + closed.complete(); + TestBed.resetTestingModule(); + }); + + it('keeps checkbox clicks and Space separate from Name sorting and detail links', () => { + const input = checkboxes()[1]; + expect(input.getAttribute('aria-label')).toContain('Alpha'); + input.dispatchEvent(new KeyboardEvent('keydown', { key: ' ', bubbles: true })); + selectFirst(); + expect(Object.keys(batch().selectedById)).toEqual([rows[0].id]); + expect(service.setSortKey).not.toHaveBeenCalled(); + expect(fixture.nativeElement.textContent).toContain('1 Selected'); + expect(fixture.nativeElement.querySelector('a').getAttribute('href')).toContain(rows[0].id); + fixture.nativeElement.querySelector('.batch-name-sort .mat-sort-header-container').click(); + expect(service.setSortKey).toHaveBeenCalledWith('name'); + }); + + it('clears a mixed header visually and keeps subsequent select-all toggles synchronized', fakeAsync(() => { + selectFirst(); + const header = checkboxes()[0]; + expect(header.indeterminate).toBe(true); + expect(header.getAttribute('aria-label')).toBe('Clear all selections'); + + header.click(); + fixture.detectChanges(); + tick(); + fixture.detectChanges(); + expect(Object.keys(batch().selectedById)).toHaveLength(0); + expect(header.checked).toBe(false); + expect(header.indeterminate).toBe(false); + expect(header.getAttribute('aria-label')).toBe('Select all loaded items'); + + header.click(); + fixture.detectChanges(); + expect(Object.keys(batch().selectedById)).toHaveLength(rows.length); + expect(header.checked).toBe(true); + expect(header.indeterminate).toBe(false); + + header.click(); + fixture.detectChanges(); + expect(Object.keys(batch().selectedById)).toHaveLength(0); + expect(checkboxes().every((input) => !input.checked && !input.indeterminate)).toBe(true); + })); + + it('retains a mixed header with no matching rows and clears hidden selections to restore Import', fakeAsync(() => { + selectFirst(); + load([]); + fixture.detectChanges(); + expect(checkboxes()).toHaveLength(1); + expect(checkboxes()[0].indeterminate).toBe(true); + expect(checkboxes()[0].getAttribute('aria-label')).toBe('Clear all selections'); + expect(fixture.nativeElement.textContent).toContain('1 Selected'); + checkboxes()[0].click(); + fixture.detectChanges(); + expect(Object.keys(batch().selectedById)).toHaveLength(0); + expect(checkboxes()[0].checked).toBe(false); + expect(checkboxes()[0].indeterminate).toBe(false); + expect(fixture.nativeElement.textContent).not.toContain('Selected'); + openMenu(); + expect(overlay.getContainerElement().textContent).toContain('Import'); + expect(overlay.getContainerElement().textContent).not.toContain('Delete'); + })); + + it('opens one immutable confirmation immediately from the selection including hidden items', fakeAsync(() => { + checkboxes()[0].click(); + fixture.detectChanges(); + load([rows[0]]); + fixture.detectChanges(); + openMenu(); + const menuItem = overlay.getContainerElement().querySelector('button[mat-menu-item]') as HTMLButtonElement; + expect(menuItem.textContent).toContain('Delete'); + menuItem.click(); + fixture.detectChanges(); + tick(); + expect(dialogs.openBatchDeleteModal).toHaveBeenCalledTimes(1); + expect(fixture.nativeElement.querySelector('.selection-status')).toBeNull(); + const [entity, snapshot] = dialogs.openBatchDeleteModal.mock.calls[0]; + expect(entity).toBe(config.entity); + expect(snapshot.items.map((item) => item.id)).toEqual(rows.map((row) => row.id)); + expect(snapshot.notShownCount).toBe(1); + closed.next(); + fixture.detectChanges(); + expect(Object.keys(batch().selectedById)).toHaveLength(2); + expect(batch().confirmation).toBeNull(); + })); + + it('keeps a disabled menu discoverable to a Reader and allows clearing the selection', () => { + store.dispatch(actionSetUserInfo({ user: { email: 'test@example.com', role: UserRole.READER } })); + selectFirst(); + const trigger = fixture.nativeElement.querySelector('.section-card-menu-trigger') as HTMLButtonElement; + expect(trigger.disabled).toBe(true); + expect(trigger.parentElement.getAttribute('tabindex')).toBe('0'); + expect(trigger.parentElement.getAttribute('aria-label')).toContain('permission'); + checkboxes()[0].click(); + fixture.detectChanges(); + expect(Object.keys(batch().selectedById)).toHaveLength(0); + }); + + it('preserves selection across collapse and removes tag expansion only for confirmed removals', () => { + selectFirst(); + fixture.componentInstance.onTagsExpanded(rows[0].id, true); + fixture.componentInstance.onTagsExpanded(rows[1].id, true); + fixture.componentInstance.onSectionCardExpandChange(false); + fixture.detectChanges(); + fixture.componentInstance.onSectionCardExpandChange(true); + fixture.detectChanges(); + expect(checkboxes()[1].checked).toBe(true); + store.dispatch(actions.confirmedRemoved({ ids: [rows[0].id] })); + fixture.detectChanges(); + expect(fixture.componentInstance.expandedTagsMap.has(rows[0].id)).toBe(false); + expect(fixture.componentInstance.expandedTagsMap.has(rows[1].id)).toBe(true); + expect(fixture.debugElement.query(By.directive(RootBatchActionsDirective))).toBeTruthy(); + }); + + it('separates User Manager delete permission from create permission', () => { + permissions$.next({ experiments: { create: false }, featureFlags: { create: false }, segments: { create: false } }); + store.dispatch(actionSetUserInfo({ user: { email: 'test@example.com', role: UserRole.USER_MANAGER } })); + fixture.detectChanges(); + expect(fixture.nativeElement.querySelector('.section-card-menu-trigger')).toBeNull(); + selectFirst(); + const trigger = fixture.nativeElement.querySelector('.section-card-menu-trigger') as HTMLButtonElement; + expect(trigger).not.toBeNull(); + expect(trigger.disabled).toBe(config.entity !== 'segments'); + }); + + it('explains a hidden restriction only in the menu tooltip without inserting a status row', () => { + load([ + { + ...rows[0], + state: config.entity === 'experiments' ? EXPERIMENT_STATE.DRAFT : undefined, + status: config.entity === 'segments' ? SEGMENT_STATUS.USED : FEATURE_FLAG_STATUS.ENABLED, + }, + ]); + fixture.detectChanges(); + selectFirst(); + load([]); + fixture.detectChanges(); + fixture.componentInstance.batchUi.requestDelete(); + fixture.detectChanges(); + const trigger = fixture.nativeElement.querySelector('.section-card-menu-trigger') as HTMLButtonElement; + expect(trigger.disabled).toBe(true); + expect(trigger.parentElement.getAttribute('aria-label')).toContain('Deselect'); + expect(fixture.nativeElement.querySelector('.selection-status')).toBeNull(); + expect(fixture.nativeElement.textContent).not.toContain('Refresh selection status'); + expect(Object.keys(batch().selectedById)).toEqual([rows[0].id]); + expect(dialogs.openBatchDeleteModal).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/frontend/projects/upgrade/src/app/features/dashboard/experiments/pages/experiment-root-page/experiment-root-page-content/experiment-root-section-card/experiment-root-section-card-table/experiment-root-section-card-table.component.html b/packages/frontend/projects/upgrade/src/app/features/dashboard/experiments/pages/experiment-root-page/experiment-root-page-content/experiment-root-section-card/experiment-root-section-card-table/experiment-root-section-card-table.component.html index fcb68092d..757709aac 100644 --- a/packages/frontend/projects/upgrade/src/app/features/dashboard/experiments/pages/experiment-root-page/experiment-root-page-content/experiment-root-section-card/experiment-root-section-card-table/experiment-root-section-card-table.component.html +++ b/packages/frontend/projects/upgrade/src/app/features/dashboard/experiments/pages/experiment-root-page/experiment-root-page-content/experiment-root-section-card/experiment-root-section-card-table/experiment-root-section-card-table.component.html @@ -1,3 +1,4 @@ +@let selection = (batch.selection$ | async); @let batchState = (batch.state$ | async);
@if (isLoading$ | async) { @@ -7,6 +8,7 @@ mat-table [dataSource]="experiments || []" [trackBy]="trackByExperimentId" + [class.has-batch-selection]="selection?.selectedCount" [ngClass]="{ 'no-data': !experiments?.length }" matSort (matSortChange)="changeSorting($event)" @@ -15,27 +17,86 @@ > - - {{ EXPERIMENT_TRANSLATION_KEYS.NAME | translate }} + +
+ + +
+ {{ + selection?.selectedCount + ? ('batch-delete.selection.count' | translate : { count: selection.selectedCount }) + : (EXPERIMENT_TRANSLATION_KEYS.NAME | translate) + }} +
+
- - {{ experiment.name }} - - @if (experiment.description) { - - {{ experiment.description }} - - } +
+ +
+ + {{ experiment.name }} + + @if (experiment.description) { + + {{ experiment.description }} + + } +
+
diff --git a/packages/frontend/projects/upgrade/src/app/features/dashboard/experiments/pages/experiment-root-page/experiment-root-page-content/experiment-root-section-card/experiment-root-section-card-table/experiment-root-section-card-table.component.scss b/packages/frontend/projects/upgrade/src/app/features/dashboard/experiments/pages/experiment-root-page/experiment-root-page-content/experiment-root-section-card/experiment-root-section-card-table/experiment-root-section-card-table.component.scss index cd2fb1c5f..ea11dd5e0 100644 --- a/packages/frontend/projects/upgrade/src/app/features/dashboard/experiments/pages/experiment-root-page/experiment-root-page-content/experiment-root-section-card/experiment-root-section-card-table/experiment-root-section-card-table.component.scss +++ b/packages/frontend/projects/upgrade/src/app/features/dashboard/experiments/pages/experiment-root-page/experiment-root-page-content/experiment-root-section-card/experiment-root-section-card-table/experiment-root-section-card-table.component.scss @@ -65,7 +65,7 @@ .name-column { width: 22%; - padding-left: 32px; + padding-left: 8px; .experiment-name { color: var(--black-2); diff --git a/packages/frontend/projects/upgrade/src/app/features/dashboard/experiments/pages/experiment-root-page/experiment-root-page-content/experiment-root-section-card/experiment-root-section-card-table/experiment-root-section-card-table.component.ts b/packages/frontend/projects/upgrade/src/app/features/dashboard/experiments/pages/experiment-root-page/experiment-root-page-content/experiment-root-section-card/experiment-root-section-card-table/experiment-root-section-card-table.component.ts index fcd225e1f..e8308c680 100644 --- a/packages/frontend/projects/upgrade/src/app/features/dashboard/experiments/pages/experiment-root-page/experiment-root-page-content/experiment-root-section-card/experiment-root-section-card-table/experiment-root-section-card-table.component.ts +++ b/packages/frontend/projects/upgrade/src/app/features/dashboard/experiments/pages/experiment-root-page/experiment-root-page-content/experiment-root-section-card/experiment-root-section-card-table/experiment-root-section-card-table.component.ts @@ -1,4 +1,5 @@ import { Observable } from 'rxjs'; +import { MatCheckboxModule } from '@angular/material/checkbox'; import { ChangeDetectionStrategy, @@ -31,6 +32,7 @@ import { NgxSkeletonLoaderModule } from 'ngx-skeleton-loader'; @Component({ selector: 'app-experiment-root-section-card-table', imports: [ + MatCheckboxModule, MatTableModule, AsyncPipe, SharedModule, @@ -58,6 +60,7 @@ export class ExperimentRootSectionCardTableComponent implements AfterViewInit, O @ViewChild('bottomTrigger') bottomTrigger: ElementRef; private observer: IntersectionObserver; + readonly batch = this.experimentService.batch; constructor(private readonly experimentService: ExperimentService) {} diff --git a/packages/frontend/projects/upgrade/src/app/features/dashboard/experiments/pages/experiment-root-page/experiment-root-page-content/experiment-root-section-card/experiment-root-section-card.component.html b/packages/frontend/projects/upgrade/src/app/features/dashboard/experiments/pages/experiment-root-page/experiment-root-page-content/experiment-root-section-card/experiment-root-section-card.component.html index 860baeb00..cadc2aced 100644 --- a/packages/frontend/projects/upgrade/src/app/features/dashboard/experiments/pages/experiment-root-page/experiment-root-page-content/experiment-root-section-card/experiment-root-section-card.component.html +++ b/packages/frontend/projects/upgrade/src/app/features/dashboard/experiments/pages/experiment-root-page/experiment-root-page-content/experiment-root-section-card/experiment-root-section-card.component.html @@ -1,4 +1,11 @@ - + + @let batchView = (batchUi.view$ | async); @if (isSectionCardExpanded) { - @if (!(isInitialLoading$ | async)) { + + @if (!(isInitialLoading$ | async) && !batchView?.selectedCount) {
@@ -35,7 +45,7 @@ [isLoading$]="isLoadingExperiments$" [isSearchActive$]="isSearchActive$" [expandedTagsMap]="expandedTagsMap" - (tagsExpanded)="onTagsExpanded($event.flagId, $event.expanded)" + (tagsExpanded)="onTagsExpanded($event.experimentId, $event.expanded)" >
diff --git a/packages/frontend/projects/upgrade/src/app/features/dashboard/experiments/pages/experiment-root-page/experiment-root-page-content/experiment-root-section-card/experiment-root-section-card.component.ts b/packages/frontend/projects/upgrade/src/app/features/dashboard/experiments/pages/experiment-root-page/experiment-root-page-content/experiment-root-section-card/experiment-root-section-card.component.ts index c182f35db..fcce2cdf6 100644 --- a/packages/frontend/projects/upgrade/src/app/features/dashboard/experiments/pages/experiment-root-page/experiment-root-page-content/experiment-root-section-card/experiment-root-section-card.component.ts +++ b/packages/frontend/projects/upgrade/src/app/features/dashboard/experiments/pages/experiment-root-page/experiment-root-page-content/experiment-root-section-card/experiment-root-section-card.component.ts @@ -1,4 +1,6 @@ -import { ChangeDetectionStrategy, Component } from '@angular/core'; +import { ChangeDetectionStrategy, Component, ViewChild } from '@angular/core'; +import { RootBatchActionsDirective } from '../../../../../../../shared/directives/root-batch-actions.directive'; +import { CommonBatchSelectionStatusComponent } from '@shared-component-lib/common-batch-selection-status/common-batch-selection-status.component'; import { CommonSectionCardComponent, CommonSectionCardSearchHeaderComponent, @@ -23,6 +25,8 @@ import { StratificationFactorsService } from '../../../../../../../core/stratifi @Component({ selector: 'app-experiment-root-section-card', imports: [ + RootBatchActionsDirective, + CommonBatchSelectionStatusComponent, CommonSectionCardComponent, CommonSectionCardSearchHeaderComponent, CommonSectionCardActionButtonsComponent, @@ -37,6 +41,12 @@ import { StratificationFactorsService } from '../../../../../../../core/stratifi }) export class ExperimentRootSectionCardComponent { permissions$: Observable; + readonly batch = this.experimentService.batch; + @ViewChild(RootBatchActionsDirective) batchUi: RootBatchActionsDirective; + + reloadBatchList() { + this.experimentService.loadExperiments(true); + } experiments$ = this.experimentService.experiments$; isLoadingExperiments$ = this.experimentService.isLoadingExperiment$; isInitialLoading$ = this.experimentService.haveInitialExperimentsLoaded(); @@ -101,6 +111,10 @@ export class ExperimentRootSectionCardComponent { } onMenuButtonItemClick(action: string) { + if (action === 'batch-delete') { + this.batchUi.requestDelete(); + return; + } if (action === EXPERIMENT_BUTTON_ACTION.IMPORT) { this.dialogService.openImportExperimentModal(); } diff --git a/packages/frontend/projects/upgrade/src/app/features/dashboard/feature-flags/pages/feature-flag-root-page/feature-flag-root-page-content/feature-flag-root-section-card/feature-flag-root-section-card-table/feature-flag-root-section-card-table.component.html b/packages/frontend/projects/upgrade/src/app/features/dashboard/feature-flags/pages/feature-flag-root-page/feature-flag-root-page-content/feature-flag-root-section-card/feature-flag-root-section-card-table/feature-flag-root-section-card-table.component.html index 2901f6f0d..001244e1b 100644 --- a/packages/frontend/projects/upgrade/src/app/features/dashboard/feature-flags/pages/feature-flag-root-page/feature-flag-root-page-content/feature-flag-root-section-card/feature-flag-root-section-card-table/feature-flag-root-section-card-table.component.html +++ b/packages/frontend/projects/upgrade/src/app/features/dashboard/feature-flags/pages/feature-flag-root-page/feature-flag-root-page-content/feature-flag-root-section-card/feature-flag-root-section-card-table/feature-flag-root-section-card-table.component.html @@ -1,3 +1,4 @@ +@let selection = (batch.selection$ | async); @let batchState = (batch.state$ | async);
@if (isLoading$ | async) { @@ -7,6 +8,7 @@ mat-table [dataSource]="featureFlags" [trackBy]="trackByFlagId" + [class.has-batch-selection]="selection?.selectedCount" [ngClass]="{ 'no-data': !featureFlags?.length }" matSort (matSortChange)="changeSorting($event)" @@ -15,27 +17,82 @@ > - - {{ FLAG_TRANSLATION_KEYS.NAME | translate }} + +
+ + +
+ {{ + selection?.selectedCount + ? ('batch-delete.selection.count' | translate : { count: selection.selectedCount }) + : (FLAG_TRANSLATION_KEYS.NAME | translate) + }} +
+
- - {{ flag.name }} - - @if (flag.description) { - - {{ flag.description }} - - } +
+ +
+ + {{ flag.name }} + + @if (flag.description) { + + {{ flag.description }} + + } +
+
diff --git a/packages/frontend/projects/upgrade/src/app/features/dashboard/feature-flags/pages/feature-flag-root-page/feature-flag-root-page-content/feature-flag-root-section-card/feature-flag-root-section-card-table/feature-flag-root-section-card-table.component.scss b/packages/frontend/projects/upgrade/src/app/features/dashboard/feature-flags/pages/feature-flag-root-page/feature-flag-root-page-content/feature-flag-root-section-card/feature-flag-root-section-card-table/feature-flag-root-section-card-table.component.scss index fecfa2756..b257ece6f 100644 --- a/packages/frontend/projects/upgrade/src/app/features/dashboard/feature-flags/pages/feature-flag-root-page/feature-flag-root-page-content/feature-flag-root-section-card/feature-flag-root-section-card-table/feature-flag-root-section-card-table.component.scss +++ b/packages/frontend/projects/upgrade/src/app/features/dashboard/feature-flags/pages/feature-flag-root-page/feature-flag-root-page-content/feature-flag-root-section-card/feature-flag-root-section-card-table/feature-flag-root-section-card-table.component.scss @@ -65,7 +65,7 @@ .name-column { width: 22%; - padding-left: 32px; + padding-left: 8px; .flag-name { color: var(--black-2); diff --git a/packages/frontend/projects/upgrade/src/app/features/dashboard/feature-flags/pages/feature-flag-root-page/feature-flag-root-page-content/feature-flag-root-section-card/feature-flag-root-section-card-table/feature-flag-root-section-card-table.component.ts b/packages/frontend/projects/upgrade/src/app/features/dashboard/feature-flags/pages/feature-flag-root-page/feature-flag-root-page-content/feature-flag-root-section-card/feature-flag-root-section-card-table/feature-flag-root-section-card-table.component.ts index 49b3d441a..8a4f6aa2d 100644 --- a/packages/frontend/projects/upgrade/src/app/features/dashboard/feature-flags/pages/feature-flag-root-page/feature-flag-root-page-content/feature-flag-root-section-card/feature-flag-root-section-card-table/feature-flag-root-section-card-table.component.ts +++ b/packages/frontend/projects/upgrade/src/app/features/dashboard/feature-flags/pages/feature-flag-root-page/feature-flag-root-page-content/feature-flag-root-section-card/feature-flag-root-section-card-table/feature-flag-root-section-card-table.component.ts @@ -1,4 +1,5 @@ import { Observable } from 'rxjs'; +import { MatCheckboxModule } from '@angular/material/checkbox'; import { ChangeDetectionStrategy, @@ -30,6 +31,7 @@ import { FEATURE_FLAG_STATUS, FILTER_MODE, FLAG_SEARCH_KEY } from 'upgrade_types @Component({ selector: 'app-feature-flag-root-section-card-table', imports: [ + MatCheckboxModule, MatTableModule, AsyncPipe, SharedModule, @@ -56,6 +58,7 @@ export class FeatureFlagRootSectionCardTableComponent implements AfterViewInit, @ViewChild('bottomTrigger') bottomTrigger: ElementRef; private observer: IntersectionObserver; + readonly batch = this.featureFlagsService.batch; constructor(private featureFlagsService: FeatureFlagsService) {} diff --git a/packages/frontend/projects/upgrade/src/app/features/dashboard/feature-flags/pages/feature-flag-root-page/feature-flag-root-page-content/feature-flag-root-section-card/feature-flag-root-section-card.component.html b/packages/frontend/projects/upgrade/src/app/features/dashboard/feature-flags/pages/feature-flag-root-page/feature-flag-root-page-content/feature-flag-root-section-card/feature-flag-root-section-card.component.html index 3e339033c..e607033b8 100644 --- a/packages/frontend/projects/upgrade/src/app/features/dashboard/feature-flags/pages/feature-flag-root-page/feature-flag-root-page-content/feature-flag-root-section-card/feature-flag-root-section-card.component.html +++ b/packages/frontend/projects/upgrade/src/app/features/dashboard/feature-flags/pages/feature-flag-root-page/feature-flag-root-page-content/feature-flag-root-section-card/feature-flag-root-section-card.component.html @@ -1,4 +1,11 @@ - + + @let batchView = (batchUi.view$ | async); @if (isSectionCardExpanded) { - @if (!(isInitialLoading$ | async)) { + + @if (!(isInitialLoading$ | async) && !batchView?.selectedCount) {
diff --git a/packages/frontend/projects/upgrade/src/app/features/dashboard/feature-flags/pages/feature-flag-root-page/feature-flag-root-page-content/feature-flag-root-section-card/feature-flag-root-section-card.component.ts b/packages/frontend/projects/upgrade/src/app/features/dashboard/feature-flags/pages/feature-flag-root-page/feature-flag-root-page-content/feature-flag-root-section-card/feature-flag-root-section-card.component.ts index eef3f5294..f35f21768 100644 --- a/packages/frontend/projects/upgrade/src/app/features/dashboard/feature-flags/pages/feature-flag-root-page/feature-flag-root-page-content/feature-flag-root-section-card/feature-flag-root-section-card.component.ts +++ b/packages/frontend/projects/upgrade/src/app/features/dashboard/feature-flags/pages/feature-flag-root-page/feature-flag-root-page-content/feature-flag-root-section-card/feature-flag-root-section-card.component.ts @@ -1,4 +1,6 @@ -import { ChangeDetectionStrategy, Component } from '@angular/core'; +import { ChangeDetectionStrategy, Component, ViewChild } from '@angular/core'; +import { RootBatchActionsDirective } from '../../../../../../../shared/directives/root-batch-actions.directive'; +import { CommonBatchSelectionStatusComponent } from '@shared-component-lib/common-batch-selection-status/common-batch-selection-status.component'; import { CommonSectionCardComponent, CommonSectionCardSearchHeaderComponent, @@ -24,6 +26,8 @@ import { AuthService } from '../../../../../../../core/auth/auth.service'; @Component({ selector: 'app-feature-flag-root-section-card', imports: [ + RootBatchActionsDirective, + CommonBatchSelectionStatusComponent, CommonSectionCardComponent, CommonSectionCardSearchHeaderComponent, CommonSectionCardActionButtonsComponent, @@ -39,7 +43,13 @@ import { AuthService } from '../../../../../../../core/auth/auth.service'; }) export class FeatureFlagRootSectionCardComponent { permissions$: Observable; - featureFlags$: Observable; + readonly batch = this.featureFlagService.batch; + @ViewChild(RootBatchActionsDirective) batchUi: RootBatchActionsDirective; + + reloadBatchList() { + this.featureFlagService.fetchFeatureFlags(true); + } + featureFlags$: Observable = this.featureFlagService.featureFlags$; isLoadingFeatureFlags$ = this.featureFlagService.isLoadingFeatureFlags$; isInitialLoading$ = this.featureFlagService.isInitialFeatureFlagsLoading$; isAllFlagsFetched$ = this.featureFlagService.isAllFlagsFetched$; @@ -86,10 +96,6 @@ export class FeatureFlagRootSectionCardComponent { this.featureFlagService.fetchFeatureFlags(true); } - ngAfterViewInit() { - this.featureFlags$ = this.featureFlagService.featureFlags$; - } - onSearch(params: CommonSearchWidgetSearchParams) { this.featureFlagService.setSearchString(params.searchString?.trim() || ''); this.featureFlagService.setSearchKey(params.searchKey as FLAG_SEARCH_KEY); @@ -100,6 +106,10 @@ export class FeatureFlagRootSectionCardComponent { } onMenuButtonItemClick(action: string) { + if (action === 'batch-delete') { + this.batchUi.requestDelete(); + return; + } if (action === FEATURE_FLAG_BUTTON_ACTION.IMPORT) { this.dialogService.openImportFeatureFlagModal(); } diff --git a/packages/frontend/projects/upgrade/src/app/features/dashboard/segments/pages/segment-root-page/segment-root-page-content/segment-root-section-card/segment-root-section-card-table/segment-root-section-card-table.component.html b/packages/frontend/projects/upgrade/src/app/features/dashboard/segments/pages/segment-root-page/segment-root-page-content/segment-root-section-card/segment-root-section-card-table/segment-root-section-card-table.component.html index e0e3e68e2..561d35451 100644 --- a/packages/frontend/projects/upgrade/src/app/features/dashboard/segments/pages/segment-root-page/segment-root-page-content/segment-root-section-card/segment-root-section-card-table/segment-root-section-card-table.component.html +++ b/packages/frontend/projects/upgrade/src/app/features/dashboard/segments/pages/segment-root-page/segment-root-page-content/segment-root-section-card/segment-root-section-card-table/segment-root-section-card-table.component.html @@ -1,3 +1,4 @@ +@let selection = (batch.selection$ | async); @let batchState = (batch.state$ | async);
@if (isLoading$ | async) { @@ -7,6 +8,7 @@ mat-table [dataSource]="segments" [trackBy]="trackBySegmentId" + [class.has-batch-selection]="selection?.selectedCount" [ngClass]="{ 'no-data': !segments?.length }" matSort (matSortChange)="changeSorting($event)" @@ -15,27 +17,86 @@ > - - {{ SEGMENT_TRANSLATION_KEYS.NAME | translate }} + +
+ + +
+ {{ + selection?.selectedCount + ? ('batch-delete.selection.count' | translate : { count: selection.selectedCount }) + : (SEGMENT_TRANSLATION_KEYS.NAME | translate) + }} +
+
- - {{ segment.name }} - - @if (segment.description) { - - {{ segment.description }} - - } +
+ +
+ + {{ segment.name }} + + @if (segment.description) { + + {{ segment.description }} + + } +
+
diff --git a/packages/frontend/projects/upgrade/src/app/features/dashboard/segments/pages/segment-root-page/segment-root-page-content/segment-root-section-card/segment-root-section-card-table/segment-root-section-card-table.component.scss b/packages/frontend/projects/upgrade/src/app/features/dashboard/segments/pages/segment-root-page/segment-root-page-content/segment-root-section-card/segment-root-section-card-table/segment-root-section-card-table.component.scss index ba5a9ee1a..3ffb78244 100644 --- a/packages/frontend/projects/upgrade/src/app/features/dashboard/segments/pages/segment-root-page/segment-root-page-content/segment-root-section-card/segment-root-section-card-table/segment-root-section-card-table.component.scss +++ b/packages/frontend/projects/upgrade/src/app/features/dashboard/segments/pages/segment-root-page/segment-root-page-content/segment-root-section-card/segment-root-section-card-table/segment-root-section-card-table.component.scss @@ -65,7 +65,7 @@ .name-column { width: 22%; - padding-left: 32px; + padding-left: 8px; .segment-name { color: var(--black-2); diff --git a/packages/frontend/projects/upgrade/src/app/features/dashboard/segments/pages/segment-root-page/segment-root-page-content/segment-root-section-card/segment-root-section-card-table/segment-root-section-card-table.component.ts b/packages/frontend/projects/upgrade/src/app/features/dashboard/segments/pages/segment-root-page/segment-root-page-content/segment-root-section-card/segment-root-section-card-table/segment-root-section-card-table.component.ts index cc800d982..fe500c01f 100644 --- a/packages/frontend/projects/upgrade/src/app/features/dashboard/segments/pages/segment-root-page/segment-root-page-content/segment-root-section-card/segment-root-section-card-table/segment-root-section-card-table.component.ts +++ b/packages/frontend/projects/upgrade/src/app/features/dashboard/segments/pages/segment-root-page/segment-root-page-content/segment-root-section-card/segment-root-section-card-table/segment-root-section-card-table.component.ts @@ -1,4 +1,5 @@ import { Observable } from 'rxjs'; +import { MatCheckboxModule } from '@angular/material/checkbox'; import { ChangeDetectionStrategy, @@ -30,6 +31,7 @@ import { @Component({ selector: 'app-segment-root-section-card-table', imports: [ + MatCheckboxModule, MatTableModule, AsyncPipe, SharedModule, @@ -55,6 +57,7 @@ export class SegmentRootSectionCardTableComponent implements AfterViewInit, OnDe @ViewChild('bottomTrigger') bottomTrigger: ElementRef; private observer: IntersectionObserver; + readonly batch = this.segmentsService.batch; constructor(private segmentsService: SegmentsService) {} diff --git a/packages/frontend/projects/upgrade/src/app/features/dashboard/segments/pages/segment-root-page/segment-root-page-content/segment-root-section-card/segment-root-section-card.component.html b/packages/frontend/projects/upgrade/src/app/features/dashboard/segments/pages/segment-root-page/segment-root-page-content/segment-root-section-card/segment-root-section-card.component.html index 8f94b291d..3dcc132d9 100644 --- a/packages/frontend/projects/upgrade/src/app/features/dashboard/segments/pages/segment-root-page/segment-root-page-content/segment-root-section-card/segment-root-section-card.component.html +++ b/packages/frontend/projects/upgrade/src/app/features/dashboard/segments/pages/segment-root-page/segment-root-page-content/segment-root-section-card/segment-root-section-card.component.html @@ -1,4 +1,11 @@ - + + @let batchView = (batchUi.view$ | async); @if (isSectionCardExpanded) { - @if (!(isInitialLoading$ | async)) { + + @if (!(isInitialLoading$ | async) && !batchView?.selectedCount) {
diff --git a/packages/frontend/projects/upgrade/src/app/features/dashboard/segments/pages/segment-root-page/segment-root-page-content/segment-root-section-card/segment-root-section-card.component.ts b/packages/frontend/projects/upgrade/src/app/features/dashboard/segments/pages/segment-root-page/segment-root-page-content/segment-root-section-card/segment-root-section-card.component.ts index d359d9ad9..861cc1f71 100644 --- a/packages/frontend/projects/upgrade/src/app/features/dashboard/segments/pages/segment-root-page/segment-root-page-content/segment-root-section-card/segment-root-section-card.component.ts +++ b/packages/frontend/projects/upgrade/src/app/features/dashboard/segments/pages/segment-root-page/segment-root-page-content/segment-root-section-card/segment-root-section-card.component.ts @@ -1,4 +1,6 @@ -import { ChangeDetectionStrategy, Component } from '@angular/core'; +import { ChangeDetectionStrategy, Component, ViewChild } from '@angular/core'; +import { RootBatchActionsDirective } from '../../../../../../../shared/directives/root-batch-actions.directive'; +import { CommonBatchSelectionStatusComponent } from '@shared-component-lib/common-batch-selection-status/common-batch-selection-status.component'; import { CommonSectionCardComponent, CommonSectionCardSearchHeaderComponent, @@ -21,6 +23,8 @@ import { AuthService } from '../../../../../../../core/auth/auth.service'; @Component({ selector: 'app-segment-root-section-card', imports: [ + RootBatchActionsDirective, + CommonBatchSelectionStatusComponent, CommonSectionCardComponent, CommonSectionCardSearchHeaderComponent, CommonSectionCardActionButtonsComponent, @@ -37,6 +41,12 @@ import { AuthService } from '../../../../../../../core/auth/auth.service'; }) export class SegmentRootSectionCardComponent { permissions$: Observable; + readonly batch = this.segmentsService.batch; + @ViewChild(RootBatchActionsDirective) batchUi: RootBatchActionsDirective; + + reloadBatchList() { + this.segmentsService.fetchSegmentsPaginated(true); + } segments$ = this.segmentsService.selectAllSegments$; isLoadingSegments$ = this.segmentsService.isLoadingSegments$; isInitialLoading$ = this.segmentsService.isInitialSegmentsLoading(); @@ -90,6 +100,10 @@ export class SegmentRootSectionCardComponent { } onMenuButtonItemClick(action: string) { + if (action === 'batch-delete') { + this.batchUi.requestDelete(); + return; + } if (action === SEGMENTS_BUTTON_ACTION.IMPORT) { this.dialogService.openImportSegmentModal(); } else if (action === SEGMENTS_BUTTON_ACTION.EXPORT_ALL) { diff --git a/packages/frontend/projects/upgrade/src/app/shared-standalone-component-lib/components/common-batch-delete-modal/common-batch-delete-modal.component.html b/packages/frontend/projects/upgrade/src/app/shared-standalone-component-lib/components/common-batch-delete-modal/common-batch-delete-modal.component.html new file mode 100644 index 000000000..de06aa132 --- /dev/null +++ b/packages/frontend/projects/upgrade/src/app/shared-standalone-component-lib/components/common-batch-delete-modal/common-batch-delete-modal.component.html @@ -0,0 +1,62 @@ + +
+ @if (!complete) { +

{{ messageKey | translate : { count: data.snapshot.items.length } }}

+ @if (data.snapshot.notShownCount) { +

+ {{ + 'batch-delete.dialog.hidden.' + (data.snapshot.notShownCount === 1 ? 'one' : 'other') + | translate : { count: data.snapshot.notShownCount } + }} +

+ } @if (stale) { +

{{ 'batch-delete.dialog.stale' | translate }}

+ } + + + + + @if (busy) { +
+ {{ + (reconciling + ? 'batch-delete.dialog.reconciling' + : 'batch-delete.dialog.deleting.' + (data.snapshot.items.length === 1 ? 'one' : 'other') + ) | translate : { count: data.snapshot.items.length } + }} + +
+ } } @else { +

{{ 'batch-delete.result.' + data.entity | translate : counts }}

+ @if (counts.uncertain) { +

{{ 'batch-delete.dialog.uncertain' | translate }}

+ } +
    + @for (item of resultItems; track item.id) { +
  • + {{ item.name }}{{ item.messageKey | translate }} +
  • + } +
+ @if (counts.remaining) { +

{{ 'batch-delete.dialog.review-remaining' | translate }}

+ } } +
+
diff --git a/packages/frontend/projects/upgrade/src/app/shared-standalone-component-lib/components/common-batch-delete-modal/common-batch-delete-modal.component.scss b/packages/frontend/projects/upgrade/src/app/shared-standalone-component-lib/components/common-batch-delete-modal/common-batch-delete-modal.component.scss new file mode 100644 index 000000000..9a5588e4b --- /dev/null +++ b/packages/frontend/projects/upgrade/src/app/shared-standalone-component-lib/components/common-batch-delete-modal/common-batch-delete-modal.component.scss @@ -0,0 +1,26 @@ +.batch-delete-content { + display: flex; + flex-direction: column; + gap: 16px; + padding: 32px 32px 16px; + color: var(--dark-grey); + + p { + margin: 0; + } + mat-progress-bar { + margin-top: 12px; + } +} + +.batch-results { + padding-left: 20px; + margin: 0; + li { + margin-bottom: 12px; + overflow-wrap: anywhere; + } + span { + display: block; + } +} diff --git a/packages/frontend/projects/upgrade/src/app/shared-standalone-component-lib/components/common-batch-delete-modal/common-batch-delete-modal.component.spec.ts b/packages/frontend/projects/upgrade/src/app/shared-standalone-component-lib/components/common-batch-delete-modal/common-batch-delete-modal.component.spec.ts new file mode 100644 index 000000000..fd5fe397f --- /dev/null +++ b/packages/frontend/projects/upgrade/src/app/shared-standalone-component-lib/components/common-batch-delete-modal/common-batch-delete-modal.component.spec.ts @@ -0,0 +1,218 @@ +import { TestBed, fakeAsync, tick } from '@angular/core/testing'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { MatDialog, MatDialogModule, MatDialogRef } from '@angular/material/dialog'; +import { OverlayContainer } from '@angular/cdk/overlay'; +import { TranslateModule, TranslateService } from '@ngx-translate/core'; +import { BehaviorSubject } from 'rxjs'; +import { BatchDeleteEntity, BatchDeleteResult, UserRole } from 'upgrade_types'; +import { RootBatchState, initialRootBatchState } from '../../../core/batch-actions/batch-actions.models'; +import { DialogService } from '../../../shared/services/common-dialog.service'; +import { CommonBatchDeleteModalComponent } from './common-batch-delete-modal.component'; + +const translations = jest.requireActual('../../../../assets/i18n/en.json'); + +describe('Batch delete confirmation', () => { + let ref: MatDialogRef; + let state$: BehaviorSubject; + let facade: any; + let container: HTMLElement; + + function open(entity: BatchDeleteEntity = 'experiments', count = 2, hidden = 0) { + const items = Array.from({ length: count }, (_, index) => ({ id: String(index), name: `Item ${index + 1}` })); + const snapshot = { operationId: 'operation', revision: 1, items, notShownCount: hidden }; + state$ = new BehaviorSubject({ + ...initialRootBatchState, + userEmail: 'test@example.com', + role: UserRole.ADMIN, + selectedById: Object.fromEntries(items.map((item) => [item.id, item])), + confirmation: snapshot, + }); + facade = { + state$, + submit: jest.fn(() => + state$.next({ + ...state$.value, + confirmation: null, + operation: { snapshot, status: 'submitting', reconciledAbsentIds: [] }, + }) + ), + }; + ref = TestBed.inject(DialogService).openBatchDeleteModal(entity, snapshot, facade); + detect(); + tick(); + } + function detect() { + ref.componentRef.changeDetectorRef.detectChanges(); + } + function input(value: string) { + const field = container.querySelector('input') as HTMLInputElement; + field.value = value; + field.dispatchEvent(new Event('input')); + detect(); + tick(); + } + const primary = () => container.querySelector('.footer-container button:not(.cancel-btn)') as HTMLButtonElement; + function finish(result: BatchDeleteResult, reconciledAbsentIds: string[] = []) { + state$.next({ + ...state$.value, + operation: { ...state$.value.operation, status: 'complete', result, reconciledAbsentIds }, + }); + detect(); + tick(); + } + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [CommonBatchDeleteModalComponent, MatDialogModule, NoopAnimationsModule, TranslateModule.forRoot()], + }).compileComponents(); + const translate = TestBed.inject(TranslateService); + translate.setTranslation('en', translations); + translate.use('en'); + container = TestBed.inject(OverlayContainer).getContainerElement(); + }); + afterEach(() => { + TestBed.inject(MatDialog).closeAll(); + state$?.complete(); + TestBed.resetTestingModule(); + }); + + it.each([ + ['experiments', 1, 'Delete Experiments', '1 experiment'], + ['experiments', 3, 'Delete Experiments', '3 experiments'], + ['flags', 1, 'Delete Feature Flags', '1 feature flag'], + ['flags', 3, 'Delete Feature Flags', '3 feature flags'], + ['segments', 1, 'Delete Segments', '1 segment'], + ['segments', 3, 'Delete Segments', '3 segments'], + ] as const)( + 'uses the fixed %s title with a count of %i', + fakeAsync((entity, count, title, phrase) => { + open(entity, count, 1); + expect(container.querySelector('h4').textContent).toBe(title); + expect(container.textContent).toContain(`Are you sure you want to delete ${phrase}?`); + expect(container.textContent).toContain('1 selected item is not shown'); + expect(container.querySelector('label').getAttribute('for')).toBe(container.querySelector('input').id); + expect(primary().disabled).toBe(true); + expect(facade.submit).not.toHaveBeenCalled(); + ref.close(); + tick(); + }) + ); + + it('requires the keyword, accepts trimmed mixed case, submits once, and blocks dismissal while pending', fakeAsync(() => { + open(); + input('wrong'); + expect(primary().disabled).toBe(true); + input(' DeLeTe '); + expect(primary().disabled).toBe(false); + primary().click(); + detect(); + tick(); + primary().click(); + container.querySelector('input').dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })); + expect(facade.submit).toHaveBeenCalledTimes(1); + expect(facade.submit).toHaveBeenCalledWith('operation'); + expect(ref.disableClose).toBe(true); + expect((container.querySelector('input') as HTMLInputElement).disabled).toBe(true); + expect(Array.from(container.querySelectorAll('button')).every((button) => button.disabled)).toBe(true); + expect(container.textContent).toContain('Deleting 2 selected items'); + ref.close(); + tick(); + })); + + it.each(['cancel', 'close', 'escape', 'backdrop'])( + 'allows %s before submission without changing selection', + fakeAsync((mode) => { + open(); + if (mode === 'cancel') (container.querySelector('.cancel-btn') as HTMLElement).click(); + if (mode === 'close') (container.querySelector('.close-btn') as HTMLElement).click(); + if (mode === 'escape') + container + .querySelector('mat-dialog-container') + .dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', keyCode: 27, bubbles: true })); + if (mode === 'backdrop') (container.querySelector('.cdk-overlay-backdrop') as HTMLElement).click(); + tick(); + expect(TestBed.inject(MatDialog).openDialogs).toHaveLength(0); + expect(facade.submit).not.toHaveBeenCalled(); + expect(Object.keys(state$.value.selectedById)).toHaveLength(2); + }) + ); + + it('closes only its own dialog after confirmed full success', fakeAsync(() => { + open(); + input('delete'); + primary().click(); + detect(); + finish({ + phase: 'executed', + results: [ + { id: '0', outcome: 'deleted' }, + { id: '1', outcome: 'deleted' }, + ], + }); + expect(TestBed.inject(MatDialog).openDialogs).toHaveLength(0); + })); + + it('shows per-item partial results and prevents resubmitting the completed snapshot', fakeAsync(() => { + open(); + input('delete'); + primary().click(); + detect(); + finish({ + phase: 'executed', + results: [ + { id: '0', outcome: 'deleted' }, + { id: '1', outcome: 'failed' }, + ], + }); + expect(container.textContent).toContain('Item 1'); + expect(container.textContent).toContain('Item 2'); + expect(container.textContent).toContain('Could not be deleted.'); + expect(primary()).toBeNull(); + expect(ref.disableClose).toBe(false); + expect(container.querySelector('.cancel-btn').textContent.trim()).toBe('Close'); + ref.componentInstance.submit(); + expect(facade.submit).toHaveBeenCalledTimes(1); + ref.close(); + tick(); + })); + + it('keeps a post-commit warning visible even though the item was deleted', fakeAsync(() => { + open('flags', 1); + input('delete'); + primary().click(); + detect(); + finish({ phase: 'executed', results: [{ id: '0', outcome: 'deleted', reasonCode: 'post_delete_failed' as any }] }); + expect(container.textContent).toContain('Deleted, but related updates could not be completed.'); + expect(TestBed.inject(MatDialog).openDialogs).toHaveLength(1); + ref.close(); + tick(); + })); + + it('distinguishes unconfirmed absences and keeps dismissal blocked during reconciliation', fakeAsync(() => { + open('segments', 1); + input('delete'); + primary().click(); + detect(); + state$.next({ ...state$.value, operation: { ...state$.value.operation, status: 'reconciling' } }); + detect(); + expect(ref.disableClose).toBe(true); + expect(container.textContent).toContain('Checking the remaining items'); + finish({ phase: 'executed', results: [{ id: '0', outcome: 'unknown' }] }, ['0']); + expect(container.textContent).toContain("this request's deletion outcome could not be confirmed"); + expect(container.textContent).toContain('The original request may still be running'); + ref.close(); + tick(); + })); + + it('blocks a stale confirmation when permissions or selection change before submission', fakeAsync(() => { + open(); + input('delete'); + state$.next({ ...state$.value, confirmation: null }); + detect(); + expect(primary().disabled).toBe(true); + expect(container.textContent).toContain('selection or permissions have changed'); + ref.componentInstance.submit(); + expect(facade.submit).not.toHaveBeenCalled(); + ref.close(); + tick(); + })); +}); diff --git a/packages/frontend/projects/upgrade/src/app/shared-standalone-component-lib/components/common-batch-delete-modal/common-batch-delete-modal.component.ts b/packages/frontend/projects/upgrade/src/app/shared-standalone-component-lib/components/common-batch-delete-modal/common-batch-delete-modal.component.ts new file mode 100644 index 000000000..c4eafe017 --- /dev/null +++ b/packages/frontend/projects/upgrade/src/app/shared-standalone-component-lib/components/common-batch-delete-modal/common-batch-delete-modal.component.ts @@ -0,0 +1,101 @@ +import { ChangeDetectionStrategy, ChangeDetectorRef, Component, DestroyRef, Inject } from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; +import { MatInputModule } from '@angular/material/input'; +import { MatProgressBarModule } from '@angular/material/progress-bar'; +import { FormsModule } from '@angular/forms'; +import { TranslateModule } from '@ngx-translate/core'; +import { BatchDeleteEntity } from 'upgrade_types'; +import { BatchFacade } from '../../../core/batch-actions/batch-actions.facade'; +import { BatchDeleteSnapshot, RootBatchState } from '../../../core/batch-actions/batch-actions.models'; +import { batchResultCounts } from '../../../core/batch-actions/batch-actions.helpers'; +import { CommonModalComponent } from '../common-modal/common-modal.component'; + +export interface BatchDeleteModalData { + entity: BatchDeleteEntity; + snapshot: BatchDeleteSnapshot; + facade: BatchFacade; +} + +@Component({ + selector: 'app-common-batch-delete-modal', + imports: [CommonModalComponent, FormsModule, MatInputModule, MatProgressBarModule, TranslateModule], + templateUrl: './common-batch-delete-modal.component.html', + styleUrl: './common-batch-delete-modal.component.scss', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class CommonBatchDeleteModalComponent { + inputValue = ''; + busy = false; + complete = false; + stale = false; + reconciling = false; + counts: ReturnType; + resultItems: { id: string; name: string; messageKey: string }[] = []; + private state: RootBatchState; + private submitted = false; + + constructor( + @Inject(MAT_DIALOG_DATA) public data: BatchDeleteModalData, + public dialogRef: MatDialogRef, + changeDetector: ChangeDetectorRef, + destroyRef: DestroyRef + ) { + data.facade.state$.pipe(takeUntilDestroyed(destroyRef)).subscribe((state) => { + this.state = state; + const operation = state.operation?.snapshot.operationId === data.snapshot.operationId ? state.operation : null; + this.busy = operation?.status === 'submitting' || operation?.status === 'reconciling'; + this.reconciling = operation?.status === 'reconciling'; + this.complete = operation?.status === 'complete'; + this.stale = !operation && state.confirmation?.operationId !== data.snapshot.operationId; + this.dialogRef.disableClose = this.busy; + if (this.complete) { + this.counts = batchResultCounts(state); + const fullSuccess = + operation.result?.results.length === data.snapshot.items.length && + operation.result.results.every((item) => item.outcome === 'deleted' && !item.reasonCode); + if (fullSuccess) { + this.dialogRef.close(true); + } else { + const results = new Map(operation.result?.results.map((item) => [item.id, item])); + this.resultItems = data.snapshot.items.map(({ id, name }) => { + const result = results.get(id); + return { + id, + name: name || id, + messageKey: operation.reconciledAbsentIds.includes(id) + ? 'batch-delete.outcome.reconciled-absent' + : result?.reasonCode + ? `batch-delete.reason.${result.reasonCode}` + : `batch-delete.outcome.${result?.outcome || 'unknown'}`, + }; + }); + } + } + changeDetector.markForCheck(); + }); + } + + get titleKey() { + return `batch-delete.dialog.${this.data.entity}.title`; + } + get messageKey() { + return `batch-delete.dialog.${this.data.entity}.${this.data.snapshot.items.length === 1 ? 'one' : 'other'}`; + } + get canSubmit() { + return ( + !this.submitted && + !this.busy && + !this.complete && + !this.stale && + this.inputValue.trim().toLowerCase() === 'delete' && + this.state.confirmation?.operationId === this.data.snapshot.operationId + ); + } + + submit() { + if (!this.canSubmit) return; + this.submitted = true; + this.data.facade.submit(this.data.snapshot.operationId); + } +} diff --git a/packages/frontend/projects/upgrade/src/app/shared-standalone-component-lib/components/common-batch-selection-status/common-batch-selection-status.component.ts b/packages/frontend/projects/upgrade/src/app/shared-standalone-component-lib/components/common-batch-selection-status/common-batch-selection-status.component.ts new file mode 100644 index 000000000..2179f238b --- /dev/null +++ b/packages/frontend/projects/upgrade/src/app/shared-standalone-component-lib/components/common-batch-selection-status/common-batch-selection-status.component.ts @@ -0,0 +1,37 @@ +import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output } from '@angular/core'; +import { MatButtonModule } from '@angular/material/button'; +import { TranslateModule } from '@ngx-translate/core'; +import type { RootBatchView } from '../../../shared/directives/root-batch-actions.directive'; + +@Component({ + selector: 'app-common-batch-selection-status', + imports: [MatButtonModule, TranslateModule], + changeDetection: ChangeDetectionStrategy.OnPush, + template: ` + @if (view?.state.listRefreshFailed) { + + } + `, + styles: [ + ` + .selection-status { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 8px; + padding: 12px 32px 0; + color: var(--dark-grey); + font-size: 14px; + } + `, + ], +}) +export class CommonBatchSelectionStatusComponent { + @Input() view: RootBatchView; + @Output() reload = new EventEmitter(); +} diff --git a/packages/frontend/projects/upgrade/src/app/shared-standalone-component-lib/components/common-modal/common-modal.component.html b/packages/frontend/projects/upgrade/src/app/shared-standalone-component-lib/components/common-modal/common-modal.component.html index d95976d75..d23e908c0 100644 --- a/packages/frontend/projects/upgrade/src/app/shared-standalone-component-lib/components/common-modal/common-modal.component.html +++ b/packages/frontend/projects/upgrade/src/app/shared-standalone-component-lib/components/common-modal/common-modal.component.html @@ -1,8 +1,14 @@
-

{{ title | translate }}

-
@@ -14,9 +20,10 @@

{{ title | translate }}

@if (!hideFooter) { }
diff --git a/packages/frontend/projects/upgrade/src/app/shared-standalone-component-lib/components/common-modal/common-modal.component.ts b/packages/frontend/projects/upgrade/src/app/shared-standalone-component-lib/components/common-modal/common-modal.component.ts index c50fc0d8c..67c3cfd55 100644 --- a/packages/frontend/projects/upgrade/src/app/shared-standalone-component-lib/components/common-modal/common-modal.component.ts +++ b/packages/frontend/projects/upgrade/src/app/shared-standalone-component-lib/components/common-modal/common-modal.component.ts @@ -26,11 +26,14 @@ import { TranslateModule } from '@ngx-translate/core'; }) export class CommonModalComponent { @Input() title = ''; + @Input() titleId?: string; @Input() cancelBtnLabel = 'Cancel'; @Input() primaryActionBtnLabel = 'Submit'; @Input() primaryActionBtnColor = 'primary'; @Input() hideFooter = false; @Input() primaryActionBtnDisabled = false; + @Input() dismissalDisabled = false; + @Input() hidePrimaryAction = false; @Output() primaryActionBtnClicked = new EventEmitter(); onPrimaryActionBtnClicked() { 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 6c7db85e2..714de676f 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 @@ -54,8 +54,22 @@
@if (showMenuButton) { -
-
diff --git a/packages/frontend/projects/upgrade/src/app/shared/directives/root-batch-actions.directive.ts b/packages/frontend/projects/upgrade/src/app/shared/directives/root-batch-actions.directive.ts new file mode 100644 index 000000000..8c85e77b6 --- /dev/null +++ b/packages/frontend/projects/upgrade/src/app/shared/directives/root-batch-actions.directive.ts @@ -0,0 +1,85 @@ +import { Directive, ElementRef, Input, OnDestroy, OnInit } from '@angular/core'; +import { MatDialogRef } from '@angular/material/dialog'; +import { Observable, Subscription, map, shareReplay } from 'rxjs'; +import { BatchDeleteEntity, IMenuButtonItem } from 'upgrade_types'; +import { BatchFacade } from '../../core/batch-actions/batch-actions.facade'; +import { selectionView } from '../../core/batch-actions/batch-actions.helpers'; +import { RootBatchState } from '../../core/batch-actions/batch-actions.models'; +import { DialogService } from '../services/common-dialog.service'; +import { CommonBatchDeleteModalComponent } from '../../shared-standalone-component-lib/components/common-batch-delete-modal/common-batch-delete-modal.component'; + +export function rootBatchView(state: RootBatchState, entity: BatchDeleteEntity) { + const selection = selectionView(state, entity); + const messageKey = selection.busy + ? 'batch-delete.selection.busy' + : selection.reasonCode + ? `batch-delete.reason.${selection.reasonCode}` + : ''; + return { + ...selection, + state, + messageKey, + menuDisabled: !selection.canRequestConfirmation || !!messageKey, + menuItems: [ + { + label: `batch-delete.menu.${entity}.${selection.selectedCount === 1 ? 'one' : 'other'}`, + action: 'batch-delete', + disabled: false, + }, + ] as IMenuButtonItem[], + }; +} + +export type RootBatchView = ReturnType; + +/** Connect root-card controls to one confirmation dialog without owning the deletion request. */ +@Directive({ selector: '[appRootBatchActions]', exportAs: 'rootBatchActions' }) +export class RootBatchActionsDirective implements OnInit, OnDestroy { + @Input() batchFacade: BatchFacade; + @Input() batchEntity: BatchDeleteEntity; + @Input() batchExpandedTags: Map; + view$: Observable; + private subscriptions = new Subscription(); + private dialogRef?: MatDialogRef; + private destroyed = false; + + constructor(private dialogs: DialogService, private host: ElementRef) {} + + ngOnInit() { + this.view$ = this.batchFacade.state$.pipe( + map((state) => rootBatchView(state, this.batchEntity)), + shareReplay({ bufferSize: 1, refCount: true }) + ); + this.subscriptions.add( + this.batchFacade.state$.subscribe((state) => { + state.removedIds.forEach((id) => this.batchExpandedTags?.delete(id)); + if (!state.confirmation || this.dialogRef) return; + const ref = this.dialogs.openBatchDeleteModal(this.batchEntity, state.confirmation, this.batchFacade); + this.dialogRef = ref; + this.subscriptions.add( + ref.afterClosed().subscribe(() => { + this.dialogRef = undefined; + this.batchFacade.dismissConfirmation(); + if (!this.destroyed) { + const target = this.host.nativeElement.querySelector( + '.section-card-menu-trigger:not(:disabled), .batch-name-sort .mat-sort-header-container' + ); + target?.focus(); + } + }) + ); + }) + ); + } + + requestDelete() { + this.batchFacade.prepareConfirmation(); + } + + ngOnDestroy() { + this.destroyed = true; + this.subscriptions.unsubscribe(); + this.dialogRef?.close(); + this.batchFacade.dismissConfirmation(); + } +} diff --git a/packages/frontend/projects/upgrade/src/app/shared/services/common-dialog.service.ts b/packages/frontend/projects/upgrade/src/app/shared/services/common-dialog.service.ts index cd9596a97..c0df98625 100644 --- a/packages/frontend/projects/upgrade/src/app/shared/services/common-dialog.service.ts +++ b/packages/frontend/projects/upgrade/src/app/shared/services/common-dialog.service.ts @@ -71,6 +71,10 @@ import { EditPayloadModalParams, } from '../../features/dashboard/experiments/modals/edit-payload-modal/edit-payload-modal.component'; import { Observable } from 'rxjs'; +import { BatchDeleteEntity } from 'upgrade_types'; +import { BatchFacade } from '../../core/batch-actions/batch-actions.facade'; +import { BatchDeleteSnapshot } from '../../core/batch-actions/batch-actions.models'; +import { CommonBatchDeleteModalComponent } from '@shared-component-lib/common-batch-delete-modal/common-batch-delete-modal.component'; export interface ImportModalParams { importTypeAdapterToken: InjectionToken; @@ -109,6 +113,17 @@ export interface UpsertMetricModalParams { export class DialogService { constructor(private dialog: MatDialog) {} + openBatchDeleteModal(entity: BatchDeleteEntity, snapshot: BatchDeleteSnapshot, facade: BatchFacade) { + return this.dialog.open(CommonBatchDeleteModalComponent, { + data: { entity, snapshot, facade }, + width: ModalSize.MEDIUM, + autoFocus: '#batch-delete-keyword', + ariaLabelledBy: 'batch-delete-title', + disableClose: false, + restoreFocus: false, + }); + } + openAddExperimentModal() { const commonModalConfig: CommonModalConfig = { title: 'Add Experiment', diff --git a/packages/frontend/projects/upgrade/src/assets/i18n/en.json b/packages/frontend/projects/upgrade/src/assets/i18n/en.json index 50c3bb3e9..b17607845 100644 --- a/packages/frontend/projects/upgrade/src/assets/i18n/en.json +++ b/packages/frontend/projects/upgrade/src/assets/i18n/en.json @@ -1,5 +1,66 @@ { - "batch-delete.selection.absent": "{{count}} selected items are already absent and were removed from the selection.", + "global.more-actions.text": "More actions", + "global.close-dialog.text": "Close dialog", + "batch-delete.close": "Close", + "batch-delete.cancel": "Cancel", + "batch-delete.delete": "Delete", + "batch-delete.selection.clear": "Clear all selections", + "batch-delete.selection.select-loaded": "Select all loaded items", + "batch-delete.selection.sort-name": "Sort by Name", + "batch-delete.selection.count": "{{count}} Selected", + "batch-delete.selection.experiments.row": "Select experiment {{name}}", + "batch-delete.selection.flags.row": "Select feature flag {{name}}", + "batch-delete.selection.segments.row": "Select segment {{name}}", + "batch-delete.selection.busy": "Deletion is in progress. Please wait for the result.", + "batch-delete.selection.list-refresh-failed": "The list could not be refreshed. Reload it to see its current contents.", + "batch-delete.selection.reload": "Reload list", + "batch-delete.menu.experiments.one": "Delete Experiment", + "batch-delete.menu.experiments.other": "Delete Experiments", + "batch-delete.menu.flags.one": "Delete Feature Flag", + "batch-delete.menu.flags.other": "Delete Feature Flags", + "batch-delete.menu.segments.one": "Delete Segment", + "batch-delete.menu.segments.other": "Delete Segments", + "batch-delete.dialog.experiments.title": "Delete Experiments", + "batch-delete.dialog.experiments.one": "Are you sure you want to delete 1 experiment?", + "batch-delete.dialog.experiments.other": "Are you sure you want to delete {{count}} experiments?", + "batch-delete.dialog.flags.title": "Delete Feature Flags", + "batch-delete.dialog.flags.one": "Are you sure you want to delete 1 feature flag?", + "batch-delete.dialog.flags.other": "Are you sure you want to delete {{count}} feature flags?", + "batch-delete.dialog.segments.title": "Delete Segments", + "batch-delete.dialog.segments.one": "Are you sure you want to delete 1 segment?", + "batch-delete.dialog.segments.other": "Are you sure you want to delete {{count}} segments?", + "batch-delete.dialog.hidden.one": "1 selected item is not shown in the current list. It will also be deleted.", + "batch-delete.dialog.hidden.other": "{{count}} selected items are not shown in the current list. They will also be deleted.", + "batch-delete.dialog.keyword-label": "Type \"delete\" to confirm deletion:", + "batch-delete.dialog.keyword-placeholder": "Type delete", + "batch-delete.dialog.stale": "The selection or permissions have changed. Close this dialog and review your selection.", + "batch-delete.dialog.deleting.one": "Deleting 1 selected item…", + "batch-delete.dialog.deleting.other": "Deleting {{count}} selected items…", + "batch-delete.dialog.reconciling": "Checking the remaining items…", + "batch-delete.dialog.uncertain": "Some deletion outcomes could not be confirmed. The original request may still be running. Check the remaining items before trying again.", + "batch-delete.dialog.review-remaining": "Unresolved items remain selected. Close this dialog and review them before confirming another deletion.", + "batch-delete.outcome.deleted": "Deleted.", + "batch-delete.outcome.not_found": "Already absent.", + "batch-delete.outcome.ineligible": "Cannot be deleted in its current state.", + "batch-delete.outcome.forbidden": "You do not have permission to delete this item.", + "batch-delete.outcome.failed": "Could not be deleted.", + "batch-delete.outcome.unknown": "Deletion outcome could not be confirmed.", + "batch-delete.outcome.not_attempted": "Deletion was not attempted.", + "batch-delete.outcome.reconciled-absent": "Already absent; this request's deletion outcome could not be confirmed.", + "batch-delete.reason.not_found": "Already absent.", + "batch-delete.reason.missing_permission": "You do not have permission to delete the selected items.", + "batch-delete.reason.experiment_state_unsupported": "A selected experiment cannot be deleted in its current state. Deselect it to enable deletion.", + "batch-delete.reason.feature_flag_enabled": "A selected feature flag is enabled. Deselect it or disable it before deleting.", + "batch-delete.reason.feature_flag_status_unsupported": "A selected feature flag cannot be deleted in its current state. Deselect it to enable deletion.", + "batch-delete.reason.segment_in_use": "Some selected segments are in use. Deselect them to enable deletion.", + "batch-delete.reason.protected_segment_type": "Only ordinary public segments can be deleted here.", + "batch-delete.reason.eligibility_unavailable": "Deletion eligibility is unavailable. Reload the page and review the selection.", + "batch-delete.reason.delete_failed": "Could not be deleted.", + "batch-delete.reason.lock_timeout": "Another operation is using this item. Deletion could not complete.", + "batch-delete.reason.external_sync_failed": "Deletion could not complete because an external service could not be updated.", + "batch-delete.reason.outcome_unknown": "Deletion outcome could not be confirmed.", + "batch-delete.reason.post_delete_failed": "Deleted, but related updates could not be completed.", + "batch-delete.reason.batch_budget_exceeded": "Deletion was not attempted because the request took too long.", "batch-delete.success.experiments.one": "1 experiment deleted.", "batch-delete.success.experiments.other": "{{deleted}} experiments deleted.", "batch-delete.success.flags.one": "1 feature flag deleted.", diff --git a/packages/frontend/projects/upgrade/src/styles.scss b/packages/frontend/projects/upgrade/src/styles.scss index 940972c21..6e1dd359e 100755 --- a/packages/frontend/projects/upgrade/src/styles.scss +++ b/packages/frontend/projects/upgrade/src/styles.scss @@ -41,6 +41,8 @@ @include custom-components-theme($light-theme); } +@import './styles/batch-selection'; + .dense-1 { @include mat.all-component-densities(-1); } diff --git a/packages/frontend/projects/upgrade/src/styles/_batch-selection.scss b/packages/frontend/projects/upgrade/src/styles/_batch-selection.scss new file mode 100644 index 000000000..5bcae467c --- /dev/null +++ b/packages/frontend/projects/upgrade/src/styles/_batch-selection.scss @@ -0,0 +1,56 @@ +@use '@angular/material' as mat; + +.batch-name-cell { + display: flex; + align-items: center; + gap: 8px; + min-width: 0; +} + +.batch-name-text { + flex: 1; + min-width: 0; +} + +// Match the theme's checkbox specificity; this partial is loaded after the theme. +.batch-name-cell .mat-mdc-checkbox.batch-checkbox { + flex: 0 0 40px; + width: 40px; + + @include mat.checkbox-overrides( + ( + unselected-icon-color: #adadad, + unselected-hover-icon-color: #adadad, + unselected-focus-icon-color: #adadad, + selected-hover-state-layer-opacity: 0, + unselected-hover-state-layer-opacity: 0, + selected-focus-state-layer-opacity: 0, + unselected-focus-state-layer-opacity: 0, + selected-pressed-state-layer-opacity: 0, + unselected-pressed-state-layer-opacity: 0, + ) + ); + + // Keep keyboard focus visible without leaving a circle after a pointer click. + &:has(input:focus-visible) { + outline: 2px solid var(--blue); + outline-offset: -4px; + border-radius: 4px; + } +} + +.batch-name-sort { + white-space: nowrap; +} + +@media (hover: hover) and (pointer: fine) { + .batch-checkbox { + opacity: 0; + } + .mat-mdc-row:hover .batch-checkbox, + .mat-mdc-header-row:hover .batch-checkbox, + .batch-checkbox:has(input:focus-visible), + .has-batch-selection .batch-checkbox { + opacity: 1; + } +} From 78055dad8c78df0ee178309b64263a9dd58d57b1 Mon Sep 17 00:00:00 2001 From: Zack Lee Date: Thu, 10 Sep 2026 21:13:52 -0400 Subject: [PATCH 06/61] feat: share deletion state guards between single and batch endpoints --- .../api/controllers/ExperimentController.ts | 22 ++- .../api/controllers/FeatureFlagController.ts | 13 +- .../src/api/controllers/SegmentController.ts | 17 +- .../src/api/services/DeletionStateService.ts | 99 ++++++++++ .../api/services/batch/BatchDeleteService.ts | 51 +----- .../services/batch/SegmentDeletionGuard.ts | 2 +- .../test/integration/BatchDelete/index.ts | 169 +++++++++++++++++- 7 files changed, 303 insertions(+), 70 deletions(-) create mode 100644 packages/backend/src/api/services/DeletionStateService.ts diff --git a/packages/backend/src/api/controllers/ExperimentController.ts b/packages/backend/src/api/controllers/ExperimentController.ts index 4012d9911..66f46c376 100644 --- a/packages/backend/src/api/controllers/ExperimentController.ts +++ b/packages/backend/src/api/controllers/ExperimentController.ts @@ -3,6 +3,7 @@ import { Inject } from 'typedi'; import { BatchDeleteService } from '../services/batch/BatchDeleteService'; import { BatchEntityIdsValidator } from './validators/BatchEntityIdsValidator'; import { DeletionEligibilityService } from '../services/batch/DeletionEligibilityService'; +import { DeletionStateService } from '../services/DeletionStateService'; import { Body, Get, @@ -671,7 +672,8 @@ export class ExperimentController { public importExportService: ImportExportService, public cacheService: CacheService, private deletionEligibilityService: DeletionEligibilityService, - @Inject(() => BatchDeleteService) private batchDeleteService: BatchDeleteService + @Inject(() => BatchDeleteService) private batchDeleteService: BatchDeleteService, + private deletionStateService: DeletionStateService ) {} /** @@ -1212,7 +1214,7 @@ export class ExperimentController { * schema: * $ref: '#/definitions/ExperimentResponse' * '400': - * description: ExperimentId should be a valid UUID. + * description: Invalid UUID or experiment state does not allow deletion. * '401': * description: AuthorizationRequiredError * '404': @@ -1229,21 +1231,23 @@ export class ExperimentController { ): Promise { request.logger.child({ user: currentUser }); + const executeTransaction = this.deletionStateService.transactionFor('experiments', id); // Manually check if the experiment has a mooclet ref if (env.mooclets.enabled) { const moocletExperimentRef = await this.moocletExperimentService.getMoocletExperimentRefByUpgradeExperimentId(id); if (moocletExperimentRef) { - return await this.moocletExperimentService.syncDelete({ - moocletExperimentRef, - experimentId: id, - currentUser, - logger: request.logger, - }); + return await this.moocletExperimentService.syncDelete( + { moocletExperimentRef, experimentId: id, currentUser, logger: request.logger }, + executeTransaction + ); } } - const experiment = await this.experimentService.delete(id, currentUser, { logger: request.logger }); + const experiment = await this.experimentService.delete(id, currentUser, { + logger: request.logger, + executeTransaction, + }); if (!experiment) { throw new NotFoundException('Experiment not found.'); diff --git a/packages/backend/src/api/controllers/FeatureFlagController.ts b/packages/backend/src/api/controllers/FeatureFlagController.ts index 9947bc8be..f3bf01efb 100644 --- a/packages/backend/src/api/controllers/FeatureFlagController.ts +++ b/packages/backend/src/api/controllers/FeatureFlagController.ts @@ -3,6 +3,7 @@ import { Inject } from 'typedi'; import { BatchDeleteService } from '../services/batch/BatchDeleteService'; import { BatchEntityIdsValidator } from './validators/BatchEntityIdsValidator'; import { DeletionEligibilityService } from '../services/batch/DeletionEligibilityService'; +import { DeletionStateService } from '../services/DeletionStateService'; import { JsonController, Authorized, @@ -163,7 +164,8 @@ export class FeatureFlagsController { public featureFlagService: FeatureFlagService, public experimentUserService: ExperimentUserService, private deletionEligibilityService: DeletionEligibilityService, - @Inject(() => BatchDeleteService) private batchDeleteService: BatchDeleteService + @Inject(() => BatchDeleteService) private batchDeleteService: BatchDeleteService, + private deletionStateService: DeletionStateService ) {} /** @@ -500,6 +502,8 @@ export class FeatureFlagsController { * responses: * '200': * description: Delete Feature flag By Id + * '400': + * description: Invalid UUID or feature flag is not disabled * '404': * description: Feature flag not found */ @@ -510,7 +514,12 @@ export class FeatureFlagsController { @CurrentUser() currentUser: UserDTO, @Req() request: AppRequest ): Promise { - const featureFlag = await this.featureFlagService.delete(id, currentUser, request.logger); + const featureFlag = await this.featureFlagService.delete( + id, + currentUser, + request.logger, + this.deletionStateService.transactionFor('flags', id) + ); if (!featureFlag) { throw new NotFoundException('Feature flag not found.'); diff --git a/packages/backend/src/api/controllers/SegmentController.ts b/packages/backend/src/api/controllers/SegmentController.ts index 8b06b8486..d4b22a87b 100644 --- a/packages/backend/src/api/controllers/SegmentController.ts +++ b/packages/backend/src/api/controllers/SegmentController.ts @@ -4,6 +4,7 @@ import { Inject } from 'typedi'; import { BatchDeleteService } from '../services/batch/BatchDeleteService'; import { BatchEntityIdsValidator } from './validators/BatchEntityIdsValidator'; import { DeletionEligibilityService } from '../services/batch/DeletionEligibilityService'; +import { DeletionStateService } from '../services/DeletionStateService'; import { JsonController, CurrentUser, @@ -310,7 +311,8 @@ export class SegmentController { constructor( public segmentService: SegmentService, private deletionEligibilityService: DeletionEligibilityService, - @Inject(() => BatchDeleteService) private batchDeleteService: BatchDeleteService + @Inject(() => BatchDeleteService) private batchDeleteService: BatchDeleteService, + private deletionStateService: DeletionStateService ) {} /** @@ -736,14 +738,23 @@ export class SegmentController { * '401': * description: Authorization Required Error * '500': - * description: Internal Server Error, SegmentId is not valid + * description: Internal Server Error + * '400': + * description: Invalid UUID, segment is in use, or segment is not an ordinary public segment + * '404': + * description: Segment not found */ @Delete('/:segmentId') public deleteSegment( @Params({ validate: true }) { segmentId }: SegmentIdValidator, @Req() request: AppRequest ): Promise { - return this.segmentService.deleteSegment(segmentId, request.logger); + return this.segmentService.deleteSegment( + segmentId, + request.logger, + undefined, + this.deletionStateService.transactionFor('segments', segmentId) + ); } /** diff --git a/packages/backend/src/api/services/DeletionStateService.ts b/packages/backend/src/api/services/DeletionStateService.ts new file mode 100644 index 000000000..cd6c4c152 --- /dev/null +++ b/packages/backend/src/api/services/DeletionStateService.ts @@ -0,0 +1,99 @@ +import { Service } from 'typedi'; +import { DataSource, EntityManager } from 'typeorm'; +import { HttpError } from 'routing-controllers'; +import { + BatchDeleteEntity, + BatchDeleteItemResult, + DeletionReasonCode, + getExperimentDeletionReason, + getFlagDeletionReason, +} from 'upgrade_types'; +import { InjectDataSource } from '../../typeorm-typedi-extensions'; +import { DeletionTransaction } from '../../types/DeletionTransaction'; +import { Experiment } from '../models/Experiment'; +import { FeatureFlag } from '../models/FeatureFlag'; +import { assertSegmentDeletionAllowed, SegmentDeletionBlockedError } from './batch/SegmentDeletionGuard'; + +export class DeletionBlockedError extends Error { + constructor(public readonly result: BatchDeleteItemResult) { + super(result.reasonCode); + } +} + +/** Check state under the same locks/transaction as deletion; permissions remain the caller's responsibility. */ +export async function assertDeletionStateAllowed( + entity: BatchDeleteEntity, + id: string, + manager: EntityManager +): Promise { + if (entity === 'segments') { + try { + await assertSegmentDeletionAllowed(id, manager); + } catch (error) { + if (!(error instanceof SegmentDeletionBlockedError)) throw error; + throw new DeletionBlockedError({ + id, + outcome: error.reason === 'missing' ? 'not_found' : 'ineligible', + reasonCode: + error.reason === 'missing' + ? DeletionReasonCode.NOT_FOUND + : error.reason === 'protected' + ? DeletionReasonCode.PROTECTED_SEGMENT_TYPE + : DeletionReasonCode.SEGMENT_IN_USE, + }); + } + return; + } + + await manager.query(`SELECT set_config('lock_timeout', CASE + WHEN current_setting('lock_timeout')::interval = interval '0' + OR current_setting('lock_timeout')::interval > interval '5 seconds' + THEN '5s' ELSE current_setting('lock_timeout') END, true)`); + const row = + entity === 'experiments' + ? await manager + .getRepository(Experiment) + .findOne({ where: { id }, select: { id: true, state: true }, lock: { mode: 'pessimistic_write' } }) + : await manager + .getRepository(FeatureFlag) + .findOne({ where: { id }, select: { id: true, status: true }, lock: { mode: 'pessimistic_write' } }); + if (!row) throw new DeletionBlockedError({ id, outcome: 'not_found', reasonCode: DeletionReasonCode.NOT_FOUND }); + const reason = + entity === 'experiments' + ? getExperimentDeletionReason((row as Experiment).state) + : getFlagDeletionReason((row as FeatureFlag).status); + if (reason) throw new DeletionBlockedError({ id, outcome: 'ineligible', reasonCode: reason }); +} + +const rejectionMessages: Partial> = { + [DeletionReasonCode.NOT_FOUND]: 'The item no longer exists.', + [DeletionReasonCode.EXPERIMENT_STATE_UNSUPPORTED]: 'The experiment cannot be deleted in its current state.', + [DeletionReasonCode.FEATURE_FLAG_ENABLED]: 'Disable the feature flag before deleting it.', + [DeletionReasonCode.FEATURE_FLAG_STATUS_UNSUPPORTED]: 'The feature flag cannot be deleted in its current state.', + [DeletionReasonCode.SEGMENT_IN_USE]: 'The segment is in use and cannot be deleted.', + [DeletionReasonCode.PROTECTED_SEGMENT_TYPE]: 'Only ordinary public segments can be deleted through this endpoint.', +}; + +/** Opt in at the three single-item API boundaries, not in services also used for internal list cleanup. */ +@Service() +export class DeletionStateService { + constructor(@InjectDataSource() private dataSource: DataSource) {} + + transactionFor(entity: BatchDeleteEntity, id: string): DeletionTransaction { + return async (work) => { + try { + return await this.dataSource.transaction('READ COMMITTED', async (manager) => { + await assertDeletionStateAllowed(entity, id, manager); + return work(manager); + }); + } catch (error) { + if (!(error instanceof DeletionBlockedError)) throw error; + // A state rejection is not a duplicate-key conflict (the existing middleware reserves 409 for that). + throw new HttpError( + error.result.outcome === 'not_found' ? 404 : 400, + rejectionMessages[error.result.reasonCode] || 'The item cannot be deleted in its current state.' + ); + } + }; + } +} diff --git a/packages/backend/src/api/services/batch/BatchDeleteService.ts b/packages/backend/src/api/services/batch/BatchDeleteService.ts index 1be133835..3133c9c3e 100644 --- a/packages/backend/src/api/services/batch/BatchDeleteService.ts +++ b/packages/backend/src/api/services/batch/BatchDeleteService.ts @@ -9,8 +9,6 @@ import { DeletionEligibilityItem, DeletionEligibilityResult, DeletionReasonCode, - getExperimentDeletionReason, - getFlagDeletionReason, hasBatchDeletePermission, } from 'upgrade_types'; import { env } from '../../../env'; @@ -19,25 +17,17 @@ import { InjectDataSource } from '../../../typeorm-typedi-extensions'; import { DeletionTransaction } from '../../../types/DeletionTransaction'; import { UserDTO } from '../../DTO/UserDTO'; import { MoocletError } from '../../errors/MoocletError'; -import { Experiment } from '../../models/Experiment'; -import { FeatureFlag } from '../../models/FeatureFlag'; import { ExperimentService } from '../ExperimentService'; import { FeatureFlagService } from '../FeatureFlagService'; import { MoocletExperimentService } from '../MoocletExperimentService'; import { SegmentService } from '../SegmentService'; import { DeletionEligibilityService } from './DeletionEligibilityService'; -import { assertSegmentDeletionAllowed, SegmentDeletionBlockedError } from './SegmentDeletionGuard'; +import { assertDeletionStateAllowed, DeletionBlockedError } from '../DeletionStateService'; // Admission budget: never abandon an in-flight deletion or claim it has been cancelled. // Check before starting another item and again after acquiring its eligibility locks. export const BATCH_DELETE_START_BUDGET_MS = 60_000; -class DeletionBlockedError extends Error { - constructor(public readonly result: BatchDeleteItemResult) { - super(result.reasonCode); - } -} - @Service() export class BatchDeleteService { constructor( @@ -121,24 +111,6 @@ export class BatchDeleteService { return { id: item.id, outcome, reasonCode: item.reasonCode || DeletionReasonCode.ELIGIBILITY_UNAVAILABLE }; } - private async guard(entity: BatchDeleteEntity, id: string, manager: EntityManager): Promise { - if (entity === 'segments') return; // The existing segment hook runs inside this same transaction. - const row = - entity === 'experiments' - ? await manager - .getRepository(Experiment) - .findOne({ where: { id }, select: { id: true, state: true }, lock: { mode: 'pessimistic_write' } }) - : await manager - .getRepository(FeatureFlag) - .findOne({ where: { id }, select: { id: true, status: true }, lock: { mode: 'pessimistic_write' } }); - if (!row) throw new DeletionBlockedError({ id, outcome: 'not_found', reasonCode: DeletionReasonCode.NOT_FOUND }); - const reason = - entity === 'experiments' - ? getExperimentDeletionReason((row as Experiment).state) - : getFlagDeletionReason((row as FeatureFlag).status); - if (reason) throw new DeletionBlockedError({ id, outcome: 'ineligible', reasonCode: reason }); - } - private async deleteOne( entity: BatchDeleteEntity, id: string, @@ -157,11 +129,8 @@ export class BatchDeleteService { try { await runner.connect(); await runner.startTransaction('READ COMMITTED'); - await runner.manager.query(`SELECT set_config('lock_timeout', CASE - WHEN current_setting('lock_timeout')::interval = interval '0' - OR current_setting('lock_timeout')::interval > interval '5 seconds' - THEN '5s' ELSE current_setting('lock_timeout') END, true)`); - await this.guard(entity, id, runner.manager); + // Segments use the existing beforeDelete hook inside this transaction. + if (entity !== 'segments') await assertDeletionStateAllowed(entity, id, runner.manager); if (performance.now() >= deadline) { throw new DeletionBlockedError({ id, @@ -219,7 +188,7 @@ export class BatchDeleteService { id, logger, async (manager) => { - await assertSegmentDeletionAllowed(id, manager); + await assertDeletionStateAllowed(entity, id, manager); if (performance.now() >= deadline) { throw new DeletionBlockedError({ id, @@ -242,18 +211,6 @@ export class BatchDeleteService { return { id, outcome: 'unknown', reasonCode: DeletionReasonCode.OUTCOME_UNKNOWN }; } if (error instanceof DeletionBlockedError) return error.result; - if (error instanceof SegmentDeletionBlockedError) { - return { - id, - outcome: error.reason === 'missing' ? 'not_found' : 'ineligible', - reasonCode: - error.reason === 'missing' - ? DeletionReasonCode.NOT_FOUND - : error.reason === 'protected' - ? DeletionReasonCode.PROTECTED_SEGMENT_TYPE - : DeletionReasonCode.SEGMENT_IN_USE, - }; - } return { id, outcome: 'failed', diff --git a/packages/backend/src/api/services/batch/SegmentDeletionGuard.ts b/packages/backend/src/api/services/batch/SegmentDeletionGuard.ts index 62caabb3f..78e3c747f 100644 --- a/packages/backend/src/api/services/batch/SegmentDeletionGuard.ts +++ b/packages/backend/src/api/services/batch/SegmentDeletionGuard.ts @@ -19,7 +19,7 @@ const ancestorQuery = ` SELECT id FROM ancestors ORDER BY id `; -/** Batch-only guard. Call inside the transaction that will delete this segment. */ +/** Guard for ordinary public-segment deletion. Call inside the transaction that will delete this segment. */ export async function assertSegmentDeletionAllowed(id: string, manager: EntityManager): Promise { if (!manager.queryRunner?.isTransactionActive) { throw new Error('Segment deletion guard requires an active transaction'); diff --git a/packages/backend/test/integration/BatchDelete/index.ts b/packages/backend/test/integration/BatchDelete/index.ts index f3a4e1cf4..639e29ff7 100644 --- a/packages/backend/test/integration/BatchDelete/index.ts +++ b/packages/backend/test/integration/BatchDelete/index.ts @@ -387,21 +387,174 @@ export function registerBatchDeleteTests(connections: () => [DataSource, DataSou } ); - test.each(entities)('%s single-delete route keeps its existing policy and response shape', async (entity) => { - await db.getRepository(User).update(SYSTEM_USER_EMAIL, { role: UserRole.READER }); + test.each(entities)( + '%s single-delete route keeps its existing roles, cleanup and success response', + async (entity) => { + await db.getRepository(User).update(SYSTEM_USER_EMAIL, { role: UserRole.READER }); + const [row] = await create(entity); + const [child] = await create('segments'); + const list = await ownedList(entity, row.id, child.id); + const { body } = await request(app).delete(`/api/${entity}/${row.id}`).expect(200); + expect(body).toEqual( + entity === 'segments' ? expect.objectContaining({ id: row.id }) : [expect.objectContaining({ id: row.id })] + ); + expect(await db.getRepository(model[entity]).countBy({ id: row.id })).toBe(0); + expect(await db.getRepository(Segment).countBy({ id: list.id })).toBe(0); + expect(await db.getRepository(IndividualForSegment).countBy({ segmentId: list.id })).toBe(0); + expect(await db.getRepository(Segment).countBy({ id: child.id })).toBe(1); + } + ); + + test.each(entities)('%s single-delete rejects an ineligible target before removing owned lists', async (entity) => { const [row] = await create(entity); + const list = await ownedList(entity, row.id); if (entity === 'experiments') await db.getRepository(Experiment).update(row.id, { state: EXPERIMENT_STATE.DRAFT }); else if (entity === 'flags') await db.getRepository(FeatureFlag).update(row.id, { status: FEATURE_FLAG_STATUS.ENABLED }); - else await db.getRepository(Segment).update(row.id, { type: SEGMENT_TYPE.PRIVATE }); - const { body } = await request(app).delete(`/api/${entity}/${row.id}`).expect(200); - expect(body).toEqual( - entity === 'segments' ? expect.objectContaining({ id: row.id }) : [expect.objectContaining({ id: row.id })] - ); - expect(await db.getRepository(model[entity]).countBy({ id: row.id })).toBe(0); + else { + const [flag] = await create('flags'); + await ownedList('flags', flag.id, row.id); + } + const messages = { + experiments: 'The experiment cannot be deleted in its current state.', + flags: 'Disable the feature flag before deleting it.', + segments: 'The segment is in use and cannot be deleted.', + }; + const { body } = await request(app).delete(`/api/${entity}/${row.id}`).expect(400); + expect(body.message).toBe(messages[entity]); + expect(await db.getRepository(model[entity]).countBy({ id: row.id })).toBe(1); + expect(await db.getRepository(Segment).countBy({ id: list.id })).toBe(1); + expect(await db.getRepository(IndividualForSegment).countBy({ segmentId: list.id })).toBe(1); + }); + + test.each(entities)('%s single-delete reports a missing target as 404', async (entity) => { + const { body } = await request(app).delete(`/api/${entity}/${randomUUID()}`).expect(404); + expect(body.message).toBe('The item no longer exists.'); }); + test.each(['experiments', 'flags'] as const)( + '%s single-delete waits for a concurrent state edit and rejects its committed state', + async (entity) => { + const [row] = await create(entity); + const blocker = writer.createQueryRunner(); + await blocker.connect(); + await blocker.startTransaction(); + let pending: Promise; + try { + if (entity === 'experiments') + await blocker.manager.getRepository(Experiment).update(row.id, { state: EXPERIMENT_STATE.DRAFT }); + else await blocker.manager.getRepository(FeatureFlag).update(row.id, { status: FEATURE_FLAG_STATUS.ENABLED }); + const [{ pid: writerPid }] = await blocker.query('SELECT pg_backend_pid() AS pid'); + let started: (pid: number) => void; + const transactionStarted = new Promise((resolve) => { + started = resolve; + }); + const original = db.createQueryRunner.bind(db); + jest.spyOn(db, 'createQueryRunner').mockImplementation((...args) => { + const runner = original(...args); + const start = runner.startTransaction.bind(runner); + jest.spyOn(runner, 'startTransaction').mockImplementation(async (...startArgs) => { + await start(...startArgs); + const [{ pid }] = await runner.query('SELECT pg_backend_pid() AS pid'); + started(pid); + }); + return runner; + }); + pending = request(app) + .delete(`/api/${entity}/${row.id}`) + .then((response) => response); + const deletionPid = await Promise.race([ + transactionStarted, + pending.then(() => { + throw new Error('Deletion finished before starting its transaction'); + }), + ]); + let waiting = false; + const deadline = Date.now() + 4000; + while (!waiting && Date.now() < deadline) { + [{ waiting }] = await blocker.query('SELECT $2::integer = ANY(pg_blocking_pids($1::integer)) AS waiting', [ + deletionPid, + writerPid, + ]); + if (!waiting) await new Promise((resolve) => setImmediate(resolve)); + } + expect(waiting).toBe(true); + await blocker.commitTransaction(); + expect((await pending).status).toBe(400); + expect(await db.getRepository(model[entity]).countBy({ id: row.id })).toBe(1); + } finally { + if (blocker.isTransactionActive) await blocker.rollbackTransaction(); + await pending; + await blocker.release(); + } + }, + 10000 + ); + + test.each([SEGMENT_TYPE.PRIVATE, SEGMENT_TYPE.GLOBAL_EXCLUDE])( + 'single-delete rejects a %s segment through the ordinary root endpoint', + async (type) => { + const [row] = await create('segments'); + await db.getRepository(Segment).update(row.id, { type }); + await request(app).delete(`/api/segments/${row.id}`).expect(400); + expect(await db.getRepository(Segment).countBy({ id: row.id })).toBe(1); + } + ); + + test.each(entities)( + '%s private-list endpoint still deletes lists and preserves their public children', + async (entity) => { + const [owner] = await create(entity); + const [child] = await create('segments'); + const list = await ownedList(entity, owner.id, child.id); + const path = entity === 'segments' ? 'list' : 'inclusionList'; + await request(app) + .delete(`/api/${entity}/${path}/${list.id}`) + .send(entity === 'segments' ? { parentSegmentId: owner.id } : {}) + .expect(200); + expect(await db.getRepository(model[entity]).countBy({ id: owner.id })).toBe(1); + expect(await db.getRepository(Segment).countBy({ id: child.id })).toBe(1); + expect(await db.getRepository(Segment).countBy({ id: list.id })).toBe(0); + expect(await db.getRepository(IndividualForSegment).countBy({ segmentId: list.id })).toBe(0); + } + ); + + test('single Mooclet deletion rejects an ineligible experiment before local or remote deletion', async () => { + env.mooclets.enabled = true; + const [row] = await create('experiments'); + await db.getRepository(Experiment).update(row.id, { state: EXPERIMENT_STATE.DRAFT }); + const service = Container.get(MoocletExperimentService); + jest + .spyOn(service, 'getMoocletExperimentRefByUpgradeExperimentId') + .mockResolvedValue({ id: randomUUID() } as MoocletExperimentRef); + const remote = jest.spyOn(service, 'orchestrateDeleteMoocletResources').mockResolvedValue(true); + await request(app).delete(`/api/experiments/${row.id}`).expect(400); + expect(remote).not.toHaveBeenCalled(); + expect(await db.getRepository(Experiment).countBy({ id: row.id })).toBe(1); + }); + + test.each([true, false])( + 'single Mooclet deletion preserves commit/rollback when remote deletion returns %s', + async (success) => { + env.mooclets.enabled = true; + const [row] = await create('experiments'); + const list = await ownedList('experiments', row.id); + const service = Container.get(MoocletExperimentService); + jest + .spyOn(service, 'getMoocletExperimentRefByUpgradeExperimentId') + .mockResolvedValue({ id: randomUUID() } as MoocletExperimentRef); + const remote = jest.spyOn(service, 'orchestrateDeleteMoocletResources').mockResolvedValue(success); + const { body } = await request(app) + .delete(`/api/experiments/${row.id}`) + .expect(success ? 200 : 500); + if (success) expect(body).toEqual([expect.objectContaining({ id: row.id })]); + expect(remote).toHaveBeenCalledTimes(1); + expect(await db.getRepository(Experiment).countBy({ id: row.id })).toBe(success ? 0 : 1); + expect(await db.getRepository(Segment).countBy({ id: list.id })).toBe(success ? 0 : 1); + } + ); + test.each(entities.flatMap((entity) => [20, 100, 500].map((count) => ({ entity, count }))))( '$entity deletes $count selections without repeating global eligibility reads per item', async ({ entity, count }) => { From 0668c48713db3db0724f4f3368478d2e256daf64 Mon Sep 17 00:00:00 2001 From: Zack Lee Date: Fri, 11 Sep 2026 01:05:11 -0400 Subject: [PATCH 07/61] fix: align batch deletion with existing UI patterns and continue past ineligible items --- .../api/services/batch/BatchDeleteService.ts | 15 +- .../test/integration/BatchDelete/index.ts | 56 ++++- .../services/batch/BatchDeleteService.test.ts | 51 +++- .../batch-actions/batch-actions.actions.ts | 1 + .../batch-actions/batch-actions.facade.ts | 1 + .../batch-actions/batch-actions.helpers.ts | 23 ++ .../batch-actions.integration.spec.ts | 34 +++ .../batch-actions/batch-actions.reducer.ts | 2 + .../experiments/store/experiments.effects.ts | 9 +- .../store/feature-flags.effects.ts | 9 +- .../core/segments/store/segments.effects.ts | 9 +- .../dashboard/batch-delete-ui.spec.ts | 89 ++++++- .../common-batch-delete-modal.component.html | 62 ----- .../common-batch-delete-modal.component.scss | 26 --- ...ommon-batch-delete-modal.component.spec.ts | 218 ------------------ .../common-batch-delete-modal.component.ts | 101 -------- .../common-modal/common-modal.component.html | 7 +- .../common-modal/common-modal.component.ts | 3 - .../batch-confirmation.spec.ts | 129 +++++++++++ ...alidated-confirmation-modal.component.html | 2 + .../root-batch-actions.directive.ts | 25 +- .../shared/services/common-dialog.service.ts | 40 +++- .../projects/upgrade/src/assets/i18n/en.json | 37 +-- .../frontend/projects/upgrade/src/styles.scss | 2 +- ...ction.scss => shared-batch-selection.scss} | 0 25 files changed, 429 insertions(+), 522 deletions(-) delete mode 100644 packages/frontend/projects/upgrade/src/app/shared-standalone-component-lib/components/common-batch-delete-modal/common-batch-delete-modal.component.html delete mode 100644 packages/frontend/projects/upgrade/src/app/shared-standalone-component-lib/components/common-batch-delete-modal/common-batch-delete-modal.component.scss delete mode 100644 packages/frontend/projects/upgrade/src/app/shared-standalone-component-lib/components/common-batch-delete-modal/common-batch-delete-modal.component.spec.ts delete mode 100644 packages/frontend/projects/upgrade/src/app/shared-standalone-component-lib/components/common-batch-delete-modal/common-batch-delete-modal.component.ts create mode 100644 packages/frontend/projects/upgrade/src/app/shared-standalone-component-lib/components/common-simple-text-validated-confirmation-modal/batch-confirmation.spec.ts rename packages/frontend/projects/upgrade/src/styles/{_batch-selection.scss => shared-batch-selection.scss} (100%) diff --git a/packages/backend/src/api/services/batch/BatchDeleteService.ts b/packages/backend/src/api/services/batch/BatchDeleteService.ts index 3133c9c3e..7e892b1e2 100644 --- a/packages/backend/src/api/services/batch/BatchDeleteService.ts +++ b/packages/backend/src/api/services/batch/BatchDeleteService.ts @@ -62,13 +62,15 @@ export class BatchDeleteService { })), }; } - if (!preflight.allDeletable) { - return { phase: 'rejected', results: preflight.items.map((item) => this.preflightResult(item)) }; - } - + const eligibilityById = new Map(preflight.items.map((item) => [item.id, item])); const results: BatchDeleteItemResult[] = []; let phase: BatchDeleteResult['phase'] = 'rejected'; for (const id of ids) { + const eligibility = eligibilityById.get(id); + if (!eligibility.canDelete) { + results.push(this.preflightResult(eligibility)); + continue; + } if (performance.now() >= deadline) { results.push( ...ids.slice(results.length).map( @@ -84,6 +86,8 @@ export class BatchDeleteService { phase = 'executed'; const result = await this.deleteOne(entity, id, user, logger, deadline); results.push(result); + // A changed state or an already absent target does not prevent independent items from being deleted. + if (result.outcome === 'ineligible' || result.outcome === 'not_found') continue; // A committed item with a post-delete failure must not be retried, but still stops this batch. if (result.outcome !== 'deleted' || result.reasonCode) { results.push( @@ -101,7 +105,6 @@ export class BatchDeleteService { } private preflightResult(item: DeletionEligibilityItem): BatchDeleteItemResult { - if (item.canDelete) return { id: item.id, outcome: 'not_attempted' }; const outcome = item.availability === 'not_found' ? 'not_found' @@ -210,7 +213,7 @@ export class BatchDeleteService { if (commitAttempted || (mutationStarted && !rolledBack)) { return { id, outcome: 'unknown', reasonCode: DeletionReasonCode.OUTCOME_UNKNOWN }; } - if (error instanceof DeletionBlockedError) return error.result; + if (error instanceof DeletionBlockedError && rolledBack) return error.result; return { id, outcome: 'failed', diff --git a/packages/backend/test/integration/BatchDelete/index.ts b/packages/backend/test/integration/BatchDelete/index.ts index 639e29ff7..c94485ab3 100644 --- a/packages/backend/test/integration/BatchDelete/index.ts +++ b/packages/backend/test/integration/BatchDelete/index.ts @@ -186,7 +186,7 @@ export function registerBatchDeleteTests(connections: () => [DataSource, DataSou expect(await db.getRepository(Segment).countBy({ id: child.id })).toBe(1); }); - test.each(entities)('%s rejects the entire selection when an ID is missing', async (entity) => { + test.each(entities)('%s skips a missing ID and deletes the remaining selection', async (entity) => { const [row] = await create(entity); const missing = randomUUID(); const { body } = await request(app) @@ -194,16 +194,16 @@ export function registerBatchDeleteTests(connections: () => [DataSource, DataSou .send({ ids: [row.id, missing] }) .expect(200); expect(body).toEqual({ - phase: 'rejected', + phase: 'executed', results: [ - { id: row.id, outcome: 'not_attempted' }, + { id: row.id, outcome: 'deleted' }, { id: missing, outcome: 'not_found', reasonCode: DeletionReasonCode.NOT_FOUND }, ], }); - expect(await db.getRepository(model[entity]).countBy({ id: row.id })).toBe(1); + expect(await db.getRepository(model[entity]).countBy({ id: row.id })).toBe(0); }); - test('a hidden Used segment blocks deletion of otherwise eligible selections', async () => { + test('skips a hidden Used segment and deletes an eligible selection', async () => { const [unused, child, parent] = await create('segments', 3); await ownedList('segments', parent.id, child.id); const [experiment] = await create('experiments'); @@ -215,16 +215,17 @@ export function registerBatchDeleteTests(connections: () => [DataSource, DataSou searchParams: { key: 'name', string: unused.name }, }) .expect(200); - expect(body.phase).toBe('rejected'); + expect(body.phase).toBe('executed'); expect(body.results).toEqual([ - { id: unused.id, outcome: 'not_attempted' }, + { id: unused.id, outcome: 'deleted' }, { id: child.id, outcome: 'ineligible', reasonCode: DeletionReasonCode.SEGMENT_IN_USE }, ]); - expect(await db.getRepository(Segment).countBy({ id: In([unused.id, child.id]) })).toBe(2); + expect(await db.getRepository(Segment).countBy({ id: unused.id })).toBe(0); + expect(await db.getRepository(Segment).countBy({ id: child.id })).toBe(1); }); test.each(entities)('%s rechecks eligibility after preflight using the mutation transaction', async (entity) => { - const [row] = await create(entity); + const [before, row, after] = await create(entity, 3); const service = Container.get(DeletionEligibilityService); const original = service[entity].bind(service); jest.spyOn(service, entity).mockImplementationOnce(async (ids, user) => { @@ -242,11 +243,12 @@ export function registerBatchDeleteTests(connections: () => [DataSource, DataSou }); const { body } = await request(app) .post(route(entity)) - .send({ ids: [row.id] }) + .send({ ids: [before.id, row.id, after.id] }) .expect(200); expect(body).toEqual({ phase: 'executed', results: [ + { id: before.id, outcome: 'deleted' }, { id: row.id, outcome: 'ineligible', @@ -257,11 +259,45 @@ export function registerBatchDeleteTests(connections: () => [DataSource, DataSou ? DeletionReasonCode.FEATURE_FLAG_ENABLED : DeletionReasonCode.SEGMENT_IN_USE, }, + { id: after.id, outcome: 'deleted' }, ], }); expect(await db.getRepository(model[entity]).countBy({ id: row.id })).toBe(1); + expect(await db.getRepository(model[entity]).countBy({ id: In([before.id, after.id]) })).toBe(0); }); + test.each(entities.flatMap((entity) => [0, 1, 2].map((blockedIndex) => ({ entity, blockedIndex }))))( + '$entity skips an ineligible selection at position $blockedIndex and deletes the other two', + async ({ entity, blockedIndex }) => { + const rows = await create(entity, 3); + const blocked = rows[blockedIndex]; + const lists = []; + for (const row of rows) lists.push(await ownedList(entity, row.id)); + if (entity === 'experiments') + await writer.getRepository(Experiment).update(blocked.id, { state: EXPERIMENT_STATE.DRAFT }); + else if (entity === 'flags') + await writer.getRepository(FeatureFlag).update(blocked.id, { status: FEATURE_FLAG_STATUS.ENABLED }); + else { + const [flag] = await create('flags'); + await ownedList('flags', flag.id, blocked.id, writer); + } + const { body } = await request(app) + .post(route(entity)) + .send({ ids: rows.map((row) => row.id) }) + .expect(200); + expect(body.phase).toBe('executed'); + expect(body.results.map((item) => ({ id: item.id, outcome: item.outcome }))).toEqual( + rows.map((row, index) => ({ id: row.id, outcome: index === blockedIndex ? 'ineligible' : 'deleted' })) + ); + for (const [index, row] of rows.entries()) { + const remaining = index === blockedIndex ? 1 : 0; + expect(await db.getRepository(model[entity]).countBy({ id: row.id })).toBe(remaining); + expect(await db.getRepository(Segment).countBy({ id: lists[index].id })).toBe(remaining); + expect(await db.getRepository(IndividualForSegment).countBy({ segmentId: lists[index].id })).toBe(remaining); + } + } + ); + test('reports an owned-list cleanup rollback after a committed success and stops the remaining items', async () => { const rows = await create('flags', 3); const list = await ownedList('flags', rows[1].id); diff --git a/packages/backend/test/unit/services/batch/BatchDeleteService.test.ts b/packages/backend/test/unit/services/batch/BatchDeleteService.test.ts index 51706c307..cdfd16e1e 100644 --- a/packages/backend/test/unit/services/batch/BatchDeleteService.test.ts +++ b/packages/backend/test/unit/services/batch/BatchDeleteService.test.ts @@ -129,7 +129,7 @@ describe('BatchDeleteService transaction outcomes', () => { } }); - test('rejects the whole selection when a hidden item cannot be deleted', async () => { + test('skips ineligible and missing selections while deleting eligible items', async () => { eligibility.segments.mockResolvedValue({ allDeletable: false, items: [ @@ -139,10 +139,10 @@ describe('BatchDeleteService transaction outcomes', () => { ], }); const result = await service.delete('segments', ids, user, logger); - expect(result.phase).toBe('rejected'); - expect(result.results.map((item) => item.outcome)).toEqual(['not_attempted', 'ineligible', 'not_found']); - expect(createQueryRunner).not.toHaveBeenCalled(); - expect(mutations).toEqual([]); + expect(result.phase).toBe('executed'); + expect(result.results.map((item) => item.outcome)).toEqual(['deleted', 'ineligible', 'not_found']); + expect(createQueryRunner).toHaveBeenCalledTimes(1); + expect(mutations).toEqual([ids[0]]); }); test('returns a no-mutation result when preflight cannot be completed', async () => { @@ -158,6 +158,35 @@ describe('BatchDeleteService transaction outcomes', () => { expect(createQueryRunner).not.toHaveBeenCalled(); }); + test('does not start transactions when every selection is ineligible', async () => { + eligibility.flags.mockResolvedValue({ + allDeletable: false, + items: ids.map((id) => ({ + id, + canDelete: false, + availability: 'present', + reasonCode: DeletionReasonCode.FEATURE_FLAG_ENABLED, + })), + }); + const result = await service.delete('flags', ids, user, logger); + expect(result.phase).toBe('rejected'); + expect(result.results.map((item) => item.outcome)).toEqual(['ineligible', 'ineligible', 'ineligible']); + expect(createQueryRunner).not.toHaveBeenCalled(); + }); + + test('stops when rollback fails even if the target was rejected before mutation', async () => { + configureRunner = (runner) => { + (runner.manager.getRepository as jest.Mock).mockReturnValue({ + findOne: jest.fn().mockResolvedValue({ status: 'enabled' }), + }); + (runner.rollbackTransaction as jest.Mock).mockRejectedValue(new Error('connection lost')); + }; + const result = await service.delete('flags', ids, user, logger); + expect(result.results.map((item) => item.outcome)).toEqual(['failed', 'not_attempted', 'not_attempted']); + expect(mutations).toEqual([]); + expect(createQueryRunner).toHaveBeenCalledTimes(1); + }); + test.each([0, 1])('stops after item %i fails and preserves earlier commits', async (index) => { work.mockImplementation(async (id) => { if (id === ids[index]) throw new Error('cleanup failed'); @@ -174,14 +203,16 @@ describe('BatchDeleteService transaction outcomes', () => { }); test.each(['missing', 'enabled'])('rejects a %s flag found by the locked mutation check', async (changed) => { - configureRunner = (runner) => { + configureRunner = (runner, index) => { + if (index !== 0) return; (runner.manager.getRepository as jest.Mock).mockReturnValue({ findOne: jest.fn().mockResolvedValue(changed === 'missing' ? null : { id: ids[0], status: 'enabled' }), }); }; const result = await service.delete('flags', ids, user, logger); expect(result.results[0].outcome).toBe(changed === 'missing' ? 'not_found' : 'ineligible'); - expect(mutations).toEqual([]); + expect(result.results.slice(1).map((item) => item.outcome)).toEqual(['deleted', 'deleted']); + expect(mutations).toEqual(ids.slice(1)); }); test('reports a lock timeout only after a confirmed rollback', async () => { @@ -196,7 +227,7 @@ describe('BatchDeleteService transaction outcomes', () => { async (reason) => { jest .spyOn(segmentGuard, 'assertSegmentDeletionAllowed') - .mockRejectedValue(new segmentGuard.SegmentDeletionBlockedError(ids[0], reason)); + .mockRejectedValueOnce(new segmentGuard.SegmentDeletionBlockedError(ids[0], reason)); const result = await service.delete('segments', ids, user, logger); expect(result.results[0]).toEqual({ id: ids[0], @@ -208,8 +239,8 @@ describe('BatchDeleteService transaction outcomes', () => { ? DeletionReasonCode.PROTECTED_SEGMENT_TYPE : DeletionReasonCode.SEGMENT_IN_USE, }); - expect(result.results.slice(1).every((item) => item.outcome === 'not_attempted')).toBe(true); - expect(mutations).toEqual([]); + expect(result.results.slice(1).every((item) => item.outcome === 'deleted')).toBe(true); + expect(mutations).toEqual(ids.slice(1)); expect(runners[0].rollbackTransaction).toHaveBeenCalled(); } ); diff --git a/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.actions.ts b/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.actions.ts index 2573e150f..878982638 100644 --- a/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.actions.ts +++ b/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.actions.ts @@ -8,6 +8,7 @@ export function createBatchActions(source: string) { toggleRow: createAction(`${prefix} Toggle Row`, props<{ item: RootSelectionItem }>()), toggleHeader: createAction(`${prefix} Toggle Header`, props<{ items: RootSelectionItem[] }>()), clearSelection: createAction(`${prefix} Clear Selection`), + rootPageLeft: createAction(`${prefix} Root Page Left`), confirmedRemoved: createAction(`${prefix} Confirmed Removed`, props<{ ids: string[] }>()), prepareConfirmation: createAction(`${prefix} Prepare Confirmation`, props<{ operationId: string }>()), dismissConfirmation: createAction(`${prefix} Dismiss Confirmation`), diff --git a/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.facade.ts b/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.facade.ts index aaaa154b8..df169071d 100644 --- a/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.facade.ts +++ b/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.facade.ts @@ -24,6 +24,7 @@ export function createBatchFacade( .pipe(select(selectRows), take(1)) .subscribe((rows) => store.dispatch(actions.toggleHeader({ items: rows.map(selectionItem) }))), clear: () => store.dispatch(actions.clearSelection()), + leaveRootPage: () => store.dispatch(actions.rootPageLeft()), prepareConfirmation: () => store.dispatch(actions.prepareConfirmation({ operationId: newBatchRequestId() })), dismissConfirmation: () => store.dispatch(actions.dismissConfirmation()), submit: (operationId: string) => diff --git a/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.helpers.ts b/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.helpers.ts index e1c427daf..b0192ce86 100644 --- a/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.helpers.ts +++ b/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.helpers.ts @@ -118,5 +118,28 @@ export function batchResultCounts(state: RootBatchState) { failed: results.filter((item) => ['failed', 'ineligible', 'forbidden'].includes(item.outcome)).length, notAttempted: results.filter((item) => item.outcome === 'not_attempted').length, hasErrors: results.some((item) => item.outcome !== 'deleted' || item.reasonCode), + postDeleteFailed: results.some((item) => item.reasonCode === DeletionReasonCode.POST_DELETE_FAILED), }; } + +/** One existing snackbar, with only the counts that apply to this result. */ +export function batchResultMessage( + entity: BatchDeleteEntity, + counts: ReturnType, + translate: (key: string, params?: Record) => string +): string { + const parts: string[] = []; + if (counts.deleted || !counts.hasErrors) + parts.push( + translate(`batch-delete.success.${entity}.${counts.deleted === 1 ? 'one' : 'other'}`, { deleted: counts.deleted }) + ); + for (const key of ['absent', 'failed', 'notAttempted'] as const) { + if (counts[key]) + parts.push( + translate(`batch-delete.result.${key}.${counts[key] === 1 ? 'one' : 'other'}`, { count: counts[key] }) + ); + } + if (counts.postDeleteFailed) parts.push(translate('batch-delete.result.post-delete-failed')); + if (counts.uncertain) parts.push(translate('batch-delete.result.uncertain')); + return parts.join(' '); +} diff --git a/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.integration.spec.ts b/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.integration.spec.ts index 841203de7..2b342cd54 100644 --- a/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.integration.spec.ts +++ b/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.integration.spec.ts @@ -299,6 +299,10 @@ describe.each(fixtures)('$entity batch store/effects integration', (config) => { }); expect(batch().selectedById[rows[0].id]).toBeUndefined(); expect(Object.keys(batch().selectedById)).toEqual([rows[1].id, rows[2].id]); + const noun = { experiments: 'experiment', flags: 'feature flag', segments: 'segment' }[config.entity]; + expect(notifications.showWarning).toHaveBeenCalledWith( + `1 ${noun} deleted. 1 item could not be deleted. 1 item was not attempted.` + ); expect(data[config.fetchMethod]).toHaveBeenLastCalledWith( expect.objectContaining({ skip: 0, searchParams: expect.objectContaining({ string: 'latest query' }) }), true @@ -307,6 +311,29 @@ describe.each(fixtures)('$entity batch store/effects integration', (config) => { expect(router.navigate).not.toHaveBeenCalled(); }); + it('removes two successful deletions and retains only the selection whose state changed', () => { + selectRows(); + const snapshot = prepare(); + const blocked = { + ...rows[1], + state: config.entity === 'experiments' ? EXPERIMENT_STATE.DRAFT : undefined, + status: config.entity === 'segments' ? SEGMENT_STATUS.USED : FEATURE_FLAG_STATUS.ENABLED, + }; + data[config.fetchMethod].mockReturnValueOnce(of(page([blocked]))); + store.dispatch(actions.batchDeleteRequested({ snapshot })); + response.next({ + phase: 'executed', + results: rows.map(({ id }, index) => ({ id, outcome: index === 1 ? 'ineligible' : 'deleted' })), + }); + expect(Object.keys(batch().selectedById)).toEqual([rows[1].id]); + expect(currentRows().map((row) => row.id)).toEqual([rows[1].id]); + expect(selectionView(batch(), config.entity).canRequestConfirmation).toBe(false); + const noun = { experiments: 'experiments', flags: 'feature flags', segments: 'segments' }[config.entity]; + expect(notifications.showWarning).toHaveBeenCalledTimes(1); + expect(notifications.showWarning).toHaveBeenCalledWith(`2 ${noun} deleted. 1 item could not be deleted.`); + expect(data.batchDelete).toHaveBeenCalledTimes(1); + }); + it('keeps the displayed confirmation snapshot when updated rows arrive or focus returns', fakeAsync(() => { selectRows(2); const snapshot = prepare(); @@ -358,6 +385,9 @@ describe.each(fixtures)('$entity batch store/effects integration', (config) => { ) ); expect(batchResultCounts(batch())).toMatchObject({ deleted: 0, absent: 1, remaining: 2, uncertain: true }); + expect(notifications.showWarning).toHaveBeenCalledWith( + '1 item was already absent. Some outcomes could not be confirmed. Check the list before retrying.' + ); expect(data.checkDeletionEligibility).toHaveBeenCalledTimes(1); expect(data.checkDeletionEligibility).toHaveBeenCalledWith(rows.map((row) => row.id)); expect(data.batchDelete).toHaveBeenCalledTimes(1); @@ -401,6 +431,10 @@ describe.each(fixtures)('$entity batch store/effects integration', (config) => { phase: 'executed', results: [{ id: rows[0].id, outcome: 'deleted', reasonCode: 'post_delete_failed' }], }); + const noun = { experiments: 'experiment', flags: 'feature flag', segments: 'segment' }[config.entity]; + expect(notifications.showWarning).toHaveBeenCalledWith( + `1 ${noun} deleted. Related updates could not be completed.` + ); expect(batch().selectedById[rows[0].id]).toBeUndefined(); expect(batch().operation.result.results[0].outcome).toBe('deleted'); expect(batch().listRefreshFailed).toBe(true); diff --git a/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.reducer.ts b/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.reducer.ts index 8bfadeb65..94c152634 100644 --- a/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.reducer.ts +++ b/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.reducer.ts @@ -95,6 +95,8 @@ export function reduceRootBatch( ? { ...state, listLoading: false, listRefreshFailed: action.batchRefresh } : state; if (matches(action, actions.confirmedRemoved)) return removeConfirmed(state, action.ids); + // Navigation clears the UI selection, but keeps any submitted operation and its result tracking alive. + if (matches(action, actions.rootPageLeft)) return { ...invalidateSelection(state), selectedById: {} }; if ( matches(action, actions.clearSelection) || matches(action, actions.toggleHeader) || 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 96f074e7d..d1562e23a 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 @@ -5,7 +5,7 @@ import { trackedListRequest, } from '../../batch-actions/batch-actions.effects'; import { isBatchBusy } from '../../batch-actions/batch-actions.models'; -import { batchResultCounts } from '../../batch-actions/batch-actions.helpers'; +import { batchResultCounts, batchResultMessage } from '../../batch-actions/batch-actions.helpers'; import { selectRootBatch, selectExperimentState } from './experiments.selectors'; import { actionFetchListSegmentOptions } from '../../segments/store/segments.actions'; import { Inject, Injectable } from '@angular/core'; @@ -67,12 +67,7 @@ export class ExperimentEffects { (state) => { const counts = batchResultCounts(state); const hasWarnings = counts.hasErrors || counts.uncertain; - const messageKey = hasWarnings - ? 'batch-delete.result.experiments' - : `batch-delete.success.experiments.${counts.deleted === 1 ? 'one' : 'other'}`; - const message = - this.translate.instant(messageKey, counts) + - (counts.uncertain ? ' ' + this.translate.instant('batch-delete.result.uncertain') : ''); + const message = batchResultMessage('experiments', counts, (key, params) => this.translate.instant(key, params)); if (hasWarnings) this.notificationService.showWarning(message); else this.notificationService.showSuccess(message); return [ 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 7d247488c..101e23ff1 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 @@ -5,7 +5,7 @@ import { trackedListRequest, } from '../../batch-actions/batch-actions.effects'; import { isBatchBusy } from '../../batch-actions/batch-actions.models'; -import { batchResultCounts } from '../../batch-actions/batch-actions.helpers'; +import { batchResultCounts, batchResultMessage } from '../../batch-actions/batch-actions.helpers'; import { selectRootBatch, selectFeatureFlagsState } from './feature-flags.selectors'; import { actionFetchListSegmentOptions } from '../../segments/store/segments.actions'; import { FeatureFlagsDataService } from '../feature-flags.data.service'; @@ -54,12 +54,7 @@ export class FeatureFlagsEffects { (state) => { const counts = batchResultCounts(state); const hasWarnings = counts.hasErrors || counts.uncertain; - const messageKey = hasWarnings - ? 'batch-delete.result.flags' - : `batch-delete.success.flags.${counts.deleted === 1 ? 'one' : 'other'}`; - const message = - this.translate.instant(messageKey, counts) + - (counts.uncertain ? ' ' + this.translate.instant('batch-delete.result.uncertain') : ''); + const message = batchResultMessage('flags', counts, (key, params) => this.translate.instant(key, params)); if (hasWarnings) this.notificationService.showWarning(message); else this.notificationService.showSuccess(message); return [ 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 c01345bdf..b1fc5b014 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 @@ -5,7 +5,7 @@ import { trackedListRequest, } from '../../batch-actions/batch-actions.effects'; import { isBatchBusy } from '../../batch-actions/batch-actions.models'; -import { batchResultCounts } from '../../batch-actions/batch-actions.helpers'; +import { batchResultCounts, batchResultMessage } from '../../batch-actions/batch-actions.helpers'; import { selectRootBatch, selectSegmentsState } from './segments.selectors'; import { Injectable } from '@angular/core'; import { Router } from '@angular/router'; @@ -51,12 +51,7 @@ export class SegmentsEffects { (state) => { const counts = batchResultCounts(state); const hasWarnings = counts.hasErrors || counts.uncertain; - const messageKey = hasWarnings - ? 'batch-delete.result.segments' - : `batch-delete.success.segments.${counts.deleted === 1 ? 'one' : 'other'}`; - const message = - this.translate.instant(messageKey, counts) + - (counts.uncertain ? ' ' + this.translate.instant('batch-delete.result.uncertain') : ''); + const message = batchResultMessage('segments', counts, (key, params) => this.translate.instant(key, params)); if (hasWarnings) this.notificationService.showWarning(message); else this.notificationService.showSuccess(message); return [ diff --git a/packages/frontend/projects/upgrade/src/app/features/dashboard/batch-delete-ui.spec.ts b/packages/frontend/projects/upgrade/src/app/features/dashboard/batch-delete-ui.spec.ts index 892342b2e..294b2c081 100644 --- a/packages/frontend/projects/upgrade/src/app/features/dashboard/batch-delete-ui.spec.ts +++ b/packages/frontend/projects/upgrade/src/app/features/dashboard/batch-delete-ui.spec.ts @@ -3,6 +3,7 @@ import { By } from '@angular/platform-browser'; import { NoopAnimationsModule } from '@angular/platform-browser/animations'; import { provideRouter } from '@angular/router'; import { OverlayContainer } from '@angular/cdk/overlay'; +import { MatDialog } from '@angular/material/dialog'; import { Store, StoreModule } from '@ngrx/store'; import { TranslateModule, TranslateService } from '@ngx-translate/core'; import { BehaviorSubject, Subject, Subscription, of } from 'rxjs'; @@ -58,7 +59,7 @@ describe.each(cases)('$entity root batch UI', (config) => { let subscription: Subscription; let service: any; let dialogs: any; - let closed: Subject; + let closed: Subject; let overlay: OverlayContainer; let permissions$: BehaviorSubject; const actions = config.actions.batchActions; @@ -276,7 +277,7 @@ describe.each(cases)('$entity root batch UI', (config) => { expect(entity).toBe(config.entity); expect(snapshot.items.map((item) => item.id)).toEqual(rows.map((row) => row.id)); expect(snapshot.notShownCount).toBe(1); - closed.next(); + closed.next(undefined); fixture.detectChanges(); expect(Object.keys(batch().selectedById)).toHaveLength(2); expect(batch().confirmation).toBeNull(); @@ -310,6 +311,90 @@ describe.each(cases)('$entity root batch UI', (config) => { expect(fixture.debugElement.query(By.directive(RootBatchActionsDirective))).toBeTruthy(); }); + it('clears selection on leaving the root page and starts empty when returning', () => { + selectFirst(); + fixture.destroy(); + expect(Object.keys(batch().selectedById)).toHaveLength(0); + expect(batch().confirmation).toBeNull(); + fixture = TestBed.createComponent(config.component as any); + fixture.detectChanges(); + expect(checkboxes().every((input) => !input.checked && !input.indeterminate)).toBe(true); + }); + + it('clears navigation selection without discarding an in-flight deletion or its result', () => { + selectFirst(); + service.batch.prepareConfirmation(); + const snapshot = batch().confirmation; + service.batch.submit(snapshot.operationId); + fixture.destroy(); + expect(Object.keys(batch().selectedById)).toHaveLength(0); + expect(batch().operation.snapshot).toEqual(snapshot); + expect(batch().operation.status).toBe('submitting'); + store.dispatch( + actions.batchDeleteCompleted({ + operationId: snapshot.operationId, + result: { phase: 'executed', results: [{ id: rows[0].id, outcome: 'deleted' }] }, + }) + ); + expect(batch().operation.status).toBe('complete'); + expect(batch().removedIds).toContain(rows[0].id); + }); + + it('starts deletion only after the common dialog closes with confirmation', fakeAsync(() => { + selectFirst(); + service.batch.prepareConfirmation(); + const snapshot = batch().confirmation; + expect(batch().operation?.status).not.toBe('submitting'); + closed.next(true); + fixture.detectChanges(); + expect(batch().operation.snapshot).toEqual(snapshot); + expect(batch().operation.status).toBe('submitting'); + expect(batch().confirmation).toBeNull(); + service.batch.prepareConfirmation(); + expect(dialogs.openBatchDeleteModal).toHaveBeenCalledTimes(1); + })); + + it.each(['cancel', 'close', 'confirm'] as const)( + 'uses the common dialog and does not refocus the menu trigger after %s', + fakeAsync((action) => { + const realDialogs = new DialogService(TestBed.inject(MatDialog), TestBed.inject(TranslateService)); + dialogs.openBatchDeleteModal.mockImplementation((entity, snapshot, facade) => + realDialogs.openBatchDeleteModal(entity, snapshot, facade) + ); + selectFirst(); + openMenu(); + const item = overlay.getContainerElement().querySelector('button[mat-menu-item]') as HTMLButtonElement; + expect(item.textContent.trim()).toBe(translations[`batch-delete.dialog.${config.entity}.title`]); + item.focus(); + item.click(); + fixture.detectChanges(); + tick(); + const ref = dialogs.openBatchDeleteModal.mock.results[0].value; + const container = overlay.getContainerElement(); + const input = container.querySelector('input') as HTMLInputElement; + input.focus(); + const trigger = fixture.nativeElement.querySelector('.section-card-menu-trigger') as HTMLButtonElement; + const focus = jest.spyOn(trigger, 'focus'); + if (action === 'confirm') { + input.value = 'delete'; + input.dispatchEvent(new Event('input')); + ref.componentRef.changeDetectorRef.detectChanges(); + tick(); + (container.querySelector('.footer-container button:not(.cancel-btn)') as HTMLButtonElement).click(); + } else { + (container.querySelector(`.${action}-btn`) as HTMLButtonElement).click(); + } + tick(); + fixture.detectChanges(); + expect(TestBed.inject(MatDialog).openDialogs).toHaveLength(0); + expect(focus).not.toHaveBeenCalled(); + expect(document.activeElement).not.toBe(trigger); + expect(batch().confirmation).toBeNull(); + expect(batch().operation?.status === 'submitting').toBe(action === 'confirm'); + focus.mockRestore(); + }) + ); + it('separates User Manager delete permission from create permission', () => { permissions$.next({ experiments: { create: false }, featureFlags: { create: false }, segments: { create: false } }); store.dispatch(actionSetUserInfo({ user: { email: 'test@example.com', role: UserRole.USER_MANAGER } })); diff --git a/packages/frontend/projects/upgrade/src/app/shared-standalone-component-lib/components/common-batch-delete-modal/common-batch-delete-modal.component.html b/packages/frontend/projects/upgrade/src/app/shared-standalone-component-lib/components/common-batch-delete-modal/common-batch-delete-modal.component.html deleted file mode 100644 index de06aa132..000000000 --- a/packages/frontend/projects/upgrade/src/app/shared-standalone-component-lib/components/common-batch-delete-modal/common-batch-delete-modal.component.html +++ /dev/null @@ -1,62 +0,0 @@ - -
- @if (!complete) { -

{{ messageKey | translate : { count: data.snapshot.items.length } }}

- @if (data.snapshot.notShownCount) { -

- {{ - 'batch-delete.dialog.hidden.' + (data.snapshot.notShownCount === 1 ? 'one' : 'other') - | translate : { count: data.snapshot.notShownCount } - }} -

- } @if (stale) { -

{{ 'batch-delete.dialog.stale' | translate }}

- } - - - - - @if (busy) { -
- {{ - (reconciling - ? 'batch-delete.dialog.reconciling' - : 'batch-delete.dialog.deleting.' + (data.snapshot.items.length === 1 ? 'one' : 'other') - ) | translate : { count: data.snapshot.items.length } - }} - -
- } } @else { -

{{ 'batch-delete.result.' + data.entity | translate : counts }}

- @if (counts.uncertain) { -

{{ 'batch-delete.dialog.uncertain' | translate }}

- } -
    - @for (item of resultItems; track item.id) { -
  • - {{ item.name }}{{ item.messageKey | translate }} -
  • - } -
- @if (counts.remaining) { -

{{ 'batch-delete.dialog.review-remaining' | translate }}

- } } -
-
diff --git a/packages/frontend/projects/upgrade/src/app/shared-standalone-component-lib/components/common-batch-delete-modal/common-batch-delete-modal.component.scss b/packages/frontend/projects/upgrade/src/app/shared-standalone-component-lib/components/common-batch-delete-modal/common-batch-delete-modal.component.scss deleted file mode 100644 index 9a5588e4b..000000000 --- a/packages/frontend/projects/upgrade/src/app/shared-standalone-component-lib/components/common-batch-delete-modal/common-batch-delete-modal.component.scss +++ /dev/null @@ -1,26 +0,0 @@ -.batch-delete-content { - display: flex; - flex-direction: column; - gap: 16px; - padding: 32px 32px 16px; - color: var(--dark-grey); - - p { - margin: 0; - } - mat-progress-bar { - margin-top: 12px; - } -} - -.batch-results { - padding-left: 20px; - margin: 0; - li { - margin-bottom: 12px; - overflow-wrap: anywhere; - } - span { - display: block; - } -} diff --git a/packages/frontend/projects/upgrade/src/app/shared-standalone-component-lib/components/common-batch-delete-modal/common-batch-delete-modal.component.spec.ts b/packages/frontend/projects/upgrade/src/app/shared-standalone-component-lib/components/common-batch-delete-modal/common-batch-delete-modal.component.spec.ts deleted file mode 100644 index fd5fe397f..000000000 --- a/packages/frontend/projects/upgrade/src/app/shared-standalone-component-lib/components/common-batch-delete-modal/common-batch-delete-modal.component.spec.ts +++ /dev/null @@ -1,218 +0,0 @@ -import { TestBed, fakeAsync, tick } from '@angular/core/testing'; -import { NoopAnimationsModule } from '@angular/platform-browser/animations'; -import { MatDialog, MatDialogModule, MatDialogRef } from '@angular/material/dialog'; -import { OverlayContainer } from '@angular/cdk/overlay'; -import { TranslateModule, TranslateService } from '@ngx-translate/core'; -import { BehaviorSubject } from 'rxjs'; -import { BatchDeleteEntity, BatchDeleteResult, UserRole } from 'upgrade_types'; -import { RootBatchState, initialRootBatchState } from '../../../core/batch-actions/batch-actions.models'; -import { DialogService } from '../../../shared/services/common-dialog.service'; -import { CommonBatchDeleteModalComponent } from './common-batch-delete-modal.component'; - -const translations = jest.requireActual('../../../../assets/i18n/en.json'); - -describe('Batch delete confirmation', () => { - let ref: MatDialogRef; - let state$: BehaviorSubject; - let facade: any; - let container: HTMLElement; - - function open(entity: BatchDeleteEntity = 'experiments', count = 2, hidden = 0) { - const items = Array.from({ length: count }, (_, index) => ({ id: String(index), name: `Item ${index + 1}` })); - const snapshot = { operationId: 'operation', revision: 1, items, notShownCount: hidden }; - state$ = new BehaviorSubject({ - ...initialRootBatchState, - userEmail: 'test@example.com', - role: UserRole.ADMIN, - selectedById: Object.fromEntries(items.map((item) => [item.id, item])), - confirmation: snapshot, - }); - facade = { - state$, - submit: jest.fn(() => - state$.next({ - ...state$.value, - confirmation: null, - operation: { snapshot, status: 'submitting', reconciledAbsentIds: [] }, - }) - ), - }; - ref = TestBed.inject(DialogService).openBatchDeleteModal(entity, snapshot, facade); - detect(); - tick(); - } - function detect() { - ref.componentRef.changeDetectorRef.detectChanges(); - } - function input(value: string) { - const field = container.querySelector('input') as HTMLInputElement; - field.value = value; - field.dispatchEvent(new Event('input')); - detect(); - tick(); - } - const primary = () => container.querySelector('.footer-container button:not(.cancel-btn)') as HTMLButtonElement; - function finish(result: BatchDeleteResult, reconciledAbsentIds: string[] = []) { - state$.next({ - ...state$.value, - operation: { ...state$.value.operation, status: 'complete', result, reconciledAbsentIds }, - }); - detect(); - tick(); - } - beforeEach(async () => { - await TestBed.configureTestingModule({ - imports: [CommonBatchDeleteModalComponent, MatDialogModule, NoopAnimationsModule, TranslateModule.forRoot()], - }).compileComponents(); - const translate = TestBed.inject(TranslateService); - translate.setTranslation('en', translations); - translate.use('en'); - container = TestBed.inject(OverlayContainer).getContainerElement(); - }); - afterEach(() => { - TestBed.inject(MatDialog).closeAll(); - state$?.complete(); - TestBed.resetTestingModule(); - }); - - it.each([ - ['experiments', 1, 'Delete Experiments', '1 experiment'], - ['experiments', 3, 'Delete Experiments', '3 experiments'], - ['flags', 1, 'Delete Feature Flags', '1 feature flag'], - ['flags', 3, 'Delete Feature Flags', '3 feature flags'], - ['segments', 1, 'Delete Segments', '1 segment'], - ['segments', 3, 'Delete Segments', '3 segments'], - ] as const)( - 'uses the fixed %s title with a count of %i', - fakeAsync((entity, count, title, phrase) => { - open(entity, count, 1); - expect(container.querySelector('h4').textContent).toBe(title); - expect(container.textContent).toContain(`Are you sure you want to delete ${phrase}?`); - expect(container.textContent).toContain('1 selected item is not shown'); - expect(container.querySelector('label').getAttribute('for')).toBe(container.querySelector('input').id); - expect(primary().disabled).toBe(true); - expect(facade.submit).not.toHaveBeenCalled(); - ref.close(); - tick(); - }) - ); - - it('requires the keyword, accepts trimmed mixed case, submits once, and blocks dismissal while pending', fakeAsync(() => { - open(); - input('wrong'); - expect(primary().disabled).toBe(true); - input(' DeLeTe '); - expect(primary().disabled).toBe(false); - primary().click(); - detect(); - tick(); - primary().click(); - container.querySelector('input').dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })); - expect(facade.submit).toHaveBeenCalledTimes(1); - expect(facade.submit).toHaveBeenCalledWith('operation'); - expect(ref.disableClose).toBe(true); - expect((container.querySelector('input') as HTMLInputElement).disabled).toBe(true); - expect(Array.from(container.querySelectorAll('button')).every((button) => button.disabled)).toBe(true); - expect(container.textContent).toContain('Deleting 2 selected items'); - ref.close(); - tick(); - })); - - it.each(['cancel', 'close', 'escape', 'backdrop'])( - 'allows %s before submission without changing selection', - fakeAsync((mode) => { - open(); - if (mode === 'cancel') (container.querySelector('.cancel-btn') as HTMLElement).click(); - if (mode === 'close') (container.querySelector('.close-btn') as HTMLElement).click(); - if (mode === 'escape') - container - .querySelector('mat-dialog-container') - .dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', keyCode: 27, bubbles: true })); - if (mode === 'backdrop') (container.querySelector('.cdk-overlay-backdrop') as HTMLElement).click(); - tick(); - expect(TestBed.inject(MatDialog).openDialogs).toHaveLength(0); - expect(facade.submit).not.toHaveBeenCalled(); - expect(Object.keys(state$.value.selectedById)).toHaveLength(2); - }) - ); - - it('closes only its own dialog after confirmed full success', fakeAsync(() => { - open(); - input('delete'); - primary().click(); - detect(); - finish({ - phase: 'executed', - results: [ - { id: '0', outcome: 'deleted' }, - { id: '1', outcome: 'deleted' }, - ], - }); - expect(TestBed.inject(MatDialog).openDialogs).toHaveLength(0); - })); - - it('shows per-item partial results and prevents resubmitting the completed snapshot', fakeAsync(() => { - open(); - input('delete'); - primary().click(); - detect(); - finish({ - phase: 'executed', - results: [ - { id: '0', outcome: 'deleted' }, - { id: '1', outcome: 'failed' }, - ], - }); - expect(container.textContent).toContain('Item 1'); - expect(container.textContent).toContain('Item 2'); - expect(container.textContent).toContain('Could not be deleted.'); - expect(primary()).toBeNull(); - expect(ref.disableClose).toBe(false); - expect(container.querySelector('.cancel-btn').textContent.trim()).toBe('Close'); - ref.componentInstance.submit(); - expect(facade.submit).toHaveBeenCalledTimes(1); - ref.close(); - tick(); - })); - - it('keeps a post-commit warning visible even though the item was deleted', fakeAsync(() => { - open('flags', 1); - input('delete'); - primary().click(); - detect(); - finish({ phase: 'executed', results: [{ id: '0', outcome: 'deleted', reasonCode: 'post_delete_failed' as any }] }); - expect(container.textContent).toContain('Deleted, but related updates could not be completed.'); - expect(TestBed.inject(MatDialog).openDialogs).toHaveLength(1); - ref.close(); - tick(); - })); - - it('distinguishes unconfirmed absences and keeps dismissal blocked during reconciliation', fakeAsync(() => { - open('segments', 1); - input('delete'); - primary().click(); - detect(); - state$.next({ ...state$.value, operation: { ...state$.value.operation, status: 'reconciling' } }); - detect(); - expect(ref.disableClose).toBe(true); - expect(container.textContent).toContain('Checking the remaining items'); - finish({ phase: 'executed', results: [{ id: '0', outcome: 'unknown' }] }, ['0']); - expect(container.textContent).toContain("this request's deletion outcome could not be confirmed"); - expect(container.textContent).toContain('The original request may still be running'); - ref.close(); - tick(); - })); - - it('blocks a stale confirmation when permissions or selection change before submission', fakeAsync(() => { - open(); - input('delete'); - state$.next({ ...state$.value, confirmation: null }); - detect(); - expect(primary().disabled).toBe(true); - expect(container.textContent).toContain('selection or permissions have changed'); - ref.componentInstance.submit(); - expect(facade.submit).not.toHaveBeenCalled(); - ref.close(); - tick(); - })); -}); diff --git a/packages/frontend/projects/upgrade/src/app/shared-standalone-component-lib/components/common-batch-delete-modal/common-batch-delete-modal.component.ts b/packages/frontend/projects/upgrade/src/app/shared-standalone-component-lib/components/common-batch-delete-modal/common-batch-delete-modal.component.ts deleted file mode 100644 index c4eafe017..000000000 --- a/packages/frontend/projects/upgrade/src/app/shared-standalone-component-lib/components/common-batch-delete-modal/common-batch-delete-modal.component.ts +++ /dev/null @@ -1,101 +0,0 @@ -import { ChangeDetectionStrategy, ChangeDetectorRef, Component, DestroyRef, Inject } from '@angular/core'; -import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; -import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; -import { MatInputModule } from '@angular/material/input'; -import { MatProgressBarModule } from '@angular/material/progress-bar'; -import { FormsModule } from '@angular/forms'; -import { TranslateModule } from '@ngx-translate/core'; -import { BatchDeleteEntity } from 'upgrade_types'; -import { BatchFacade } from '../../../core/batch-actions/batch-actions.facade'; -import { BatchDeleteSnapshot, RootBatchState } from '../../../core/batch-actions/batch-actions.models'; -import { batchResultCounts } from '../../../core/batch-actions/batch-actions.helpers'; -import { CommonModalComponent } from '../common-modal/common-modal.component'; - -export interface BatchDeleteModalData { - entity: BatchDeleteEntity; - snapshot: BatchDeleteSnapshot; - facade: BatchFacade; -} - -@Component({ - selector: 'app-common-batch-delete-modal', - imports: [CommonModalComponent, FormsModule, MatInputModule, MatProgressBarModule, TranslateModule], - templateUrl: './common-batch-delete-modal.component.html', - styleUrl: './common-batch-delete-modal.component.scss', - changeDetection: ChangeDetectionStrategy.OnPush, -}) -export class CommonBatchDeleteModalComponent { - inputValue = ''; - busy = false; - complete = false; - stale = false; - reconciling = false; - counts: ReturnType; - resultItems: { id: string; name: string; messageKey: string }[] = []; - private state: RootBatchState; - private submitted = false; - - constructor( - @Inject(MAT_DIALOG_DATA) public data: BatchDeleteModalData, - public dialogRef: MatDialogRef, - changeDetector: ChangeDetectorRef, - destroyRef: DestroyRef - ) { - data.facade.state$.pipe(takeUntilDestroyed(destroyRef)).subscribe((state) => { - this.state = state; - const operation = state.operation?.snapshot.operationId === data.snapshot.operationId ? state.operation : null; - this.busy = operation?.status === 'submitting' || operation?.status === 'reconciling'; - this.reconciling = operation?.status === 'reconciling'; - this.complete = operation?.status === 'complete'; - this.stale = !operation && state.confirmation?.operationId !== data.snapshot.operationId; - this.dialogRef.disableClose = this.busy; - if (this.complete) { - this.counts = batchResultCounts(state); - const fullSuccess = - operation.result?.results.length === data.snapshot.items.length && - operation.result.results.every((item) => item.outcome === 'deleted' && !item.reasonCode); - if (fullSuccess) { - this.dialogRef.close(true); - } else { - const results = new Map(operation.result?.results.map((item) => [item.id, item])); - this.resultItems = data.snapshot.items.map(({ id, name }) => { - const result = results.get(id); - return { - id, - name: name || id, - messageKey: operation.reconciledAbsentIds.includes(id) - ? 'batch-delete.outcome.reconciled-absent' - : result?.reasonCode - ? `batch-delete.reason.${result.reasonCode}` - : `batch-delete.outcome.${result?.outcome || 'unknown'}`, - }; - }); - } - } - changeDetector.markForCheck(); - }); - } - - get titleKey() { - return `batch-delete.dialog.${this.data.entity}.title`; - } - get messageKey() { - return `batch-delete.dialog.${this.data.entity}.${this.data.snapshot.items.length === 1 ? 'one' : 'other'}`; - } - get canSubmit() { - return ( - !this.submitted && - !this.busy && - !this.complete && - !this.stale && - this.inputValue.trim().toLowerCase() === 'delete' && - this.state.confirmation?.operationId === this.data.snapshot.operationId - ); - } - - submit() { - if (!this.canSubmit) return; - this.submitted = true; - this.data.facade.submit(this.data.snapshot.operationId); - } -} diff --git a/packages/frontend/projects/upgrade/src/app/shared-standalone-component-lib/components/common-modal/common-modal.component.html b/packages/frontend/projects/upgrade/src/app/shared-standalone-component-lib/components/common-modal/common-modal.component.html index d23e908c0..384eee058 100644 --- a/packages/frontend/projects/upgrade/src/app/shared-standalone-component-lib/components/common-modal/common-modal.component.html +++ b/packages/frontend/projects/upgrade/src/app/shared-standalone-component-lib/components/common-modal/common-modal.component.html @@ -1,12 +1,11 @@
-

{{ title | translate }}

+

{{ title | translate }}

- @if (!hidePrimaryAction) { - } } diff --git a/packages/frontend/projects/upgrade/src/app/shared-standalone-component-lib/components/common-modal/common-modal.component.ts b/packages/frontend/projects/upgrade/src/app/shared-standalone-component-lib/components/common-modal/common-modal.component.ts index 67c3cfd55..c50fc0d8c 100644 --- a/packages/frontend/projects/upgrade/src/app/shared-standalone-component-lib/components/common-modal/common-modal.component.ts +++ b/packages/frontend/projects/upgrade/src/app/shared-standalone-component-lib/components/common-modal/common-modal.component.ts @@ -26,14 +26,11 @@ import { TranslateModule } from '@ngx-translate/core'; }) export class CommonModalComponent { @Input() title = ''; - @Input() titleId?: string; @Input() cancelBtnLabel = 'Cancel'; @Input() primaryActionBtnLabel = 'Submit'; @Input() primaryActionBtnColor = 'primary'; @Input() hideFooter = false; @Input() primaryActionBtnDisabled = false; - @Input() dismissalDisabled = false; - @Input() hidePrimaryAction = false; @Output() primaryActionBtnClicked = new EventEmitter(); onPrimaryActionBtnClicked() { diff --git a/packages/frontend/projects/upgrade/src/app/shared-standalone-component-lib/components/common-simple-text-validated-confirmation-modal/batch-confirmation.spec.ts b/packages/frontend/projects/upgrade/src/app/shared-standalone-component-lib/components/common-simple-text-validated-confirmation-modal/batch-confirmation.spec.ts new file mode 100644 index 000000000..c49ae9479 --- /dev/null +++ b/packages/frontend/projects/upgrade/src/app/shared-standalone-component-lib/components/common-simple-text-validated-confirmation-modal/batch-confirmation.spec.ts @@ -0,0 +1,129 @@ +import { TestBed, fakeAsync, tick } from '@angular/core/testing'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { MatDialog, MatDialogModule, MatDialogRef } from '@angular/material/dialog'; +import { OverlayContainer } from '@angular/cdk/overlay'; +import { TranslateModule, TranslateService } from '@ngx-translate/core'; +import { BehaviorSubject } from 'rxjs'; +import { BatchDeleteEntity } from 'upgrade_types'; +import { initialRootBatchState, RootBatchState } from '../../../core/batch-actions/batch-actions.models'; +import { DialogService } from '../../../shared/services/common-dialog.service'; +import { CommonSimpleTextValidatedConfirmationModalComponent } from './common-simple-text-validated-confirmation-modal.component'; + +const translations = jest.requireActual('../../../../assets/i18n/en.json'); + +describe('Batch deletion using the existing text confirmation dialog', () => { + let ref: MatDialogRef; + let state$: BehaviorSubject; + let container: HTMLElement; + const primary = () => container.querySelector('.footer-container button:not(.cancel-btn)') as HTMLButtonElement; + function detect() { + ref.componentRef.changeDetectorRef.detectChanges(); + tick(); + } + function open(entity: BatchDeleteEntity = 'experiments', count = 1, hidden = 0) { + const snapshot = { + operationId: 'operation', + revision: 1, + notShownCount: hidden, + items: Array.from({ length: count }, (_, index) => ({ id: String(index), name: `Item ${index}` })), + }; + state$ = new BehaviorSubject({ ...initialRootBatchState, confirmation: snapshot }); + ref = TestBed.inject(DialogService).openBatchDeleteModal(entity, snapshot, { state$ } as any); + detect(); + } + function input(value: string) { + const field = container.querySelector('input'); + field.value = value; + field.dispatchEvent(new Event('input')); + detect(); + } + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [ + CommonSimpleTextValidatedConfirmationModalComponent, + MatDialogModule, + NoopAnimationsModule, + TranslateModule.forRoot(), + ], + }).compileComponents(); + const translate = TestBed.inject(TranslateService); + translate.setTranslation('en', translations); + translate.use('en'); + container = TestBed.inject(OverlayContainer).getContainerElement(); + }); + afterEach(() => { + TestBed.inject(MatDialog).closeAll(); + state$?.complete(); + TestBed.resetTestingModule(); + }); + + it.each([ + ['experiments', 1, 'Delete Experiments', '1 experiment'], + ['experiments', 3, 'Delete Experiments', '3 experiments'], + ['flags', 1, 'Delete Feature Flags', '1 feature flag'], + ['flags', 3, 'Delete Feature Flags', '3 feature flags'], + ['segments', 1, 'Delete Segments', '1 segment'], + ['segments', 3, 'Delete Segments', '3 segments'], + ] as const)( + 'uses common confirmation for %s with %i items', + fakeAsync((entity, count, title, phrase) => { + open(entity, count, 1); + expect(ref.componentInstance).toBeInstanceOf(CommonSimpleTextValidatedConfirmationModalComponent); + expect(container.querySelector('h4').textContent).toBe(title); + expect(container.textContent).toContain(`Are you sure you want to delete ${phrase}?`); + expect(container.textContent).toContain('1 selected item is not shown'); + expect((container.querySelector('.cdk-overlay-pane') as HTMLElement).style.width).toBe('480px'); + expect(container.querySelector('input').autocomplete).toBe('off'); + expect(primary().classList.contains('mat-warn')).toBe(true); + expect(primary().disabled).toBe(true); + }) + ); + + it('closes with confirmation before deletion, without any progress or result screen', fakeAsync(() => { + open(); + const closed = jest.fn(); + ref.afterClosed().subscribe(closed); + input('wrong'); + expect(primary().disabled).toBe(true); + input(' DeLeTe '); + expect(primary().disabled).toBe(false); + primary().click(); + detect(); + expect(closed).toHaveBeenCalledTimes(1); + expect(closed).toHaveBeenCalledWith(true); + expect(TestBed.inject(MatDialog).openDialogs).toHaveLength(0); + expect(container.textContent).not.toContain('Deleting'); + expect(container.querySelector('mat-progress-bar')).toBeNull(); + })); + + it.each(['cancel', 'close'])( + 'allows %s without confirming', + fakeAsync((action) => { + open(); + const closed = jest.fn(); + ref.afterClosed().subscribe(closed); + (container.querySelector(`.${action}-btn`) as HTMLElement).click(); + tick(); + expect(closed).toHaveBeenCalledTimes(1); + expect(closed).not.toHaveBeenCalledWith(true); + }) + ); + + it('ignores Escape and backdrop clicks', fakeAsync(() => { + open(); + container + .querySelector('mat-dialog-container') + .dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', keyCode: 27, bubbles: true })); + (container.querySelector('.cdk-overlay-backdrop') as HTMLElement).click(); + tick(); + expect(TestBed.inject(MatDialog).openDialogs).toHaveLength(1); + })); + + it('disables confirmation when selection or permissions invalidate its snapshot', fakeAsync(() => { + open(); + input('delete'); + state$.next({ ...state$.value, confirmation: null }); + detect(); + expect(primary().disabled).toBe(true); + })); +}); diff --git a/packages/frontend/projects/upgrade/src/app/shared-standalone-component-lib/components/common-simple-text-validated-confirmation-modal/common-simple-text-validated-confirmation-modal.component.html b/packages/frontend/projects/upgrade/src/app/shared-standalone-component-lib/components/common-simple-text-validated-confirmation-modal/common-simple-text-validated-confirmation-modal.component.html index b84f44c06..6017fb16f 100644 --- a/packages/frontend/projects/upgrade/src/app/shared-standalone-component-lib/components/common-simple-text-validated-confirmation-modal/common-simple-text-validated-confirmation-modal.component.html +++ b/packages/frontend/projects/upgrade/src/app/shared-standalone-component-lib/components/common-simple-text-validated-confirmation-modal/common-simple-text-validated-confirmation-modal.component.html @@ -21,6 +21,8 @@ ; view$: Observable; private subscriptions = new Subscription(); - private dialogRef?: MatDialogRef; - private destroyed = false; - - constructor(private dialogs: DialogService, private host: ElementRef) {} + private dialogRef?: MatDialogRef; + constructor(private dialogs: DialogService) {} ngOnInit() { this.view$ = this.batchFacade.state$.pipe( @@ -55,17 +53,13 @@ export class RootBatchActionsDirective implements OnInit, OnDestroy { state.removedIds.forEach((id) => this.batchExpandedTags?.delete(id)); if (!state.confirmation || this.dialogRef) return; const ref = this.dialogs.openBatchDeleteModal(this.batchEntity, state.confirmation, this.batchFacade); + const operationId = state.confirmation.operationId; this.dialogRef = ref; this.subscriptions.add( - ref.afterClosed().subscribe(() => { + ref.afterClosed().subscribe((confirmed) => { this.dialogRef = undefined; + if (confirmed) this.batchFacade.submit(operationId); this.batchFacade.dismissConfirmation(); - if (!this.destroyed) { - const target = this.host.nativeElement.querySelector( - '.section-card-menu-trigger:not(:disabled), .batch-name-sort .mat-sort-header-container' - ); - target?.focus(); - } }) ); }) @@ -77,9 +71,8 @@ export class RootBatchActionsDirective implements OnInit, OnDestroy { } ngOnDestroy() { - this.destroyed = true; this.subscriptions.unsubscribe(); this.dialogRef?.close(); - this.batchFacade.dismissConfirmation(); + this.batchFacade.leaveRootPage(); } } diff --git a/packages/frontend/projects/upgrade/src/app/shared/services/common-dialog.service.ts b/packages/frontend/projects/upgrade/src/app/shared/services/common-dialog.service.ts index c0df98625..e234a5e4d 100644 --- a/packages/frontend/projects/upgrade/src/app/shared/services/common-dialog.service.ts +++ b/packages/frontend/projects/upgrade/src/app/shared/services/common-dialog.service.ts @@ -70,11 +70,11 @@ import { EditPayloadModalComponent, EditPayloadModalParams, } from '../../features/dashboard/experiments/modals/edit-payload-modal/edit-payload-modal.component'; -import { Observable } from 'rxjs'; +import { Observable, map } from 'rxjs'; +import { TranslateService } from '@ngx-translate/core'; import { BatchDeleteEntity } from 'upgrade_types'; import { BatchFacade } from '../../core/batch-actions/batch-actions.facade'; import { BatchDeleteSnapshot } from '../../core/batch-actions/batch-actions.models'; -import { CommonBatchDeleteModalComponent } from '@shared-component-lib/common-batch-delete-modal/common-batch-delete-modal.component'; export interface ImportModalParams { importTypeAdapterToken: InjectionToken; @@ -111,17 +111,31 @@ export interface UpsertMetricModalParams { providedIn: 'root', }) export class DialogService { - constructor(private dialog: MatDialog) {} + constructor(private dialog: MatDialog, private translate: TranslateService) {} openBatchDeleteModal(entity: BatchDeleteEntity, snapshot: BatchDeleteSnapshot, facade: BatchFacade) { - return this.dialog.open(CommonBatchDeleteModalComponent, { - data: { entity, snapshot, facade }, - width: ModalSize.MEDIUM, - autoFocus: '#batch-delete-keyword', - ariaLabelledBy: 'batch-delete-title', - disableClose: false, - restoreFocus: false, - }); + const config: CommonModalConfig = { + title: `batch-delete.dialog.${entity}.title`, + primaryActionBtnLabel: 'Delete', + primaryActionBtnColor: 'warn', + cancelBtnLabel: 'Cancel', + params: { + message: this.translate.instant( + `batch-delete.dialog.${entity}.${snapshot.items.length === 1 ? 'one' : 'other'}`, + { count: snapshot.items.length } + ), + subMessage: snapshot.notShownCount + ? this.translate.instant(`batch-delete.dialog.hidden.${snapshot.notShownCount === 1 ? 'one' : 'other'}`, { + count: snapshot.notShownCount, + }) + : undefined, + validationKeyword: 'delete', + validationPlaceholder: 'Type delete', + isLoading$: facade.state$.pipe(map((state) => state.confirmation?.operationId !== snapshot.operationId)), + }, + }; + // Do not restore focus to the menu that launched the dialog, matching existing menu-based dialogs. + return this.openTextValidatedConfirmationModal(config, ModalSize.SMALL, false); } openAddExperimentModal() { @@ -1334,13 +1348,15 @@ export class DialogService { openTextValidatedConfirmationModal( commonModalConfig: CommonModalConfig, - modalSize: ModalSize = ModalSize.MEDIUM + modalSize: ModalSize = ModalSize.MEDIUM, + restoreFocus = true ): MatDialogRef { const config: MatDialogConfig = { data: commonModalConfig, width: modalSize, autoFocus: 'input', disableClose: true, + restoreFocus, }; return this.dialog.open(CommonSimpleTextValidatedConfirmationModalComponent, config); diff --git a/packages/frontend/projects/upgrade/src/assets/i18n/en.json b/packages/frontend/projects/upgrade/src/assets/i18n/en.json index b17607845..a605b16c2 100644 --- a/packages/frontend/projects/upgrade/src/assets/i18n/en.json +++ b/packages/frontend/projects/upgrade/src/assets/i18n/en.json @@ -1,9 +1,6 @@ { "global.more-actions.text": "More actions", "global.close-dialog.text": "Close dialog", - "batch-delete.close": "Close", - "batch-delete.cancel": "Cancel", - "batch-delete.delete": "Delete", "batch-delete.selection.clear": "Clear all selections", "batch-delete.selection.select-loaded": "Select all loaded items", "batch-delete.selection.sort-name": "Sort by Name", @@ -14,12 +11,6 @@ "batch-delete.selection.busy": "Deletion is in progress. Please wait for the result.", "batch-delete.selection.list-refresh-failed": "The list could not be refreshed. Reload it to see its current contents.", "batch-delete.selection.reload": "Reload list", - "batch-delete.menu.experiments.one": "Delete Experiment", - "batch-delete.menu.experiments.other": "Delete Experiments", - "batch-delete.menu.flags.one": "Delete Feature Flag", - "batch-delete.menu.flags.other": "Delete Feature Flags", - "batch-delete.menu.segments.one": "Delete Segment", - "batch-delete.menu.segments.other": "Delete Segments", "batch-delete.dialog.experiments.title": "Delete Experiments", "batch-delete.dialog.experiments.one": "Are you sure you want to delete 1 experiment?", "batch-delete.dialog.experiments.other": "Are you sure you want to delete {{count}} experiments?", @@ -31,22 +22,6 @@ "batch-delete.dialog.segments.other": "Are you sure you want to delete {{count}} segments?", "batch-delete.dialog.hidden.one": "1 selected item is not shown in the current list. It will also be deleted.", "batch-delete.dialog.hidden.other": "{{count}} selected items are not shown in the current list. They will also be deleted.", - "batch-delete.dialog.keyword-label": "Type \"delete\" to confirm deletion:", - "batch-delete.dialog.keyword-placeholder": "Type delete", - "batch-delete.dialog.stale": "The selection or permissions have changed. Close this dialog and review your selection.", - "batch-delete.dialog.deleting.one": "Deleting 1 selected item…", - "batch-delete.dialog.deleting.other": "Deleting {{count}} selected items…", - "batch-delete.dialog.reconciling": "Checking the remaining items…", - "batch-delete.dialog.uncertain": "Some deletion outcomes could not be confirmed. The original request may still be running. Check the remaining items before trying again.", - "batch-delete.dialog.review-remaining": "Unresolved items remain selected. Close this dialog and review them before confirming another deletion.", - "batch-delete.outcome.deleted": "Deleted.", - "batch-delete.outcome.not_found": "Already absent.", - "batch-delete.outcome.ineligible": "Cannot be deleted in its current state.", - "batch-delete.outcome.forbidden": "You do not have permission to delete this item.", - "batch-delete.outcome.failed": "Could not be deleted.", - "batch-delete.outcome.unknown": "Deletion outcome could not be confirmed.", - "batch-delete.outcome.not_attempted": "Deletion was not attempted.", - "batch-delete.outcome.reconciled-absent": "Already absent; this request's deletion outcome could not be confirmed.", "batch-delete.reason.not_found": "Already absent.", "batch-delete.reason.missing_permission": "You do not have permission to delete the selected items.", "batch-delete.reason.experiment_state_unsupported": "A selected experiment cannot be deleted in its current state. Deselect it to enable deletion.", @@ -67,10 +42,14 @@ "batch-delete.success.flags.other": "{{deleted}} feature flags deleted.", "batch-delete.success.segments.one": "1 segment deleted.", "batch-delete.success.segments.other": "{{deleted}} segments deleted.", - "batch-delete.result.experiments": "Experiments: {{deleted}} deleted. {{absent}} already absent. {{failed}} could not be deleted. {{notAttempted}} not attempted. {{remaining}} remain selected.", - "batch-delete.result.flags": "Feature flags: {{deleted}} deleted. {{absent}} already absent. {{failed}} could not be deleted. {{notAttempted}} not attempted. {{remaining}} remain selected.", - "batch-delete.result.segments": "Segments: {{deleted}} deleted. {{absent}} already absent. {{failed}} could not be deleted. {{notAttempted}} not attempted. {{remaining}} remain selected.", - "batch-delete.result.uncertain": "Some outcomes could not be confirmed. No deletion was retried automatically.", + "batch-delete.result.absent.one": "1 item was already absent.", + "batch-delete.result.absent.other": "{{count}} items were already absent.", + "batch-delete.result.failed.one": "1 item could not be deleted.", + "batch-delete.result.failed.other": "{{count}} items could not be deleted.", + "batch-delete.result.notAttempted.one": "1 item was not attempted.", + "batch-delete.result.notAttempted.other": "{{count}} items were not attempted.", + "batch-delete.result.post-delete-failed": "Related updates could not be completed.", + "batch-delete.result.uncertain": "Some outcomes could not be confirmed. Check the list before retrying.", "global.app.name": "UpGrade", "global.app.description": "Open Source A/B Testing Platform for Education Software", diff --git a/packages/frontend/projects/upgrade/src/styles.scss b/packages/frontend/projects/upgrade/src/styles.scss index 6e1dd359e..0964b675f 100755 --- a/packages/frontend/projects/upgrade/src/styles.scss +++ b/packages/frontend/projects/upgrade/src/styles.scss @@ -41,7 +41,7 @@ @include custom-components-theme($light-theme); } -@import './styles/batch-selection'; +@import './styles/shared-batch-selection.scss'; .dense-1 { @include mat.all-component-densities(-1); diff --git a/packages/frontend/projects/upgrade/src/styles/_batch-selection.scss b/packages/frontend/projects/upgrade/src/styles/shared-batch-selection.scss similarity index 100% rename from packages/frontend/projects/upgrade/src/styles/_batch-selection.scss rename to packages/frontend/projects/upgrade/src/styles/shared-batch-selection.scss From 0fa4e4387cc4080cdd5e016400d97656c6cf8873 Mon Sep 17 00:00:00 2001 From: Zack Lee Date: Fri, 11 Sep 2026 10:44:20 -0400 Subject: [PATCH 08/61] fix: use standard batch deletion error handling and keep selection usable after failures --- .../batch-actions/batch-actions.actions.ts | 2 +- .../batch-actions/batch-actions.effects.ts | 29 +++++++--- .../batch-actions/batch-actions.http.spec.ts | 9 ++-- .../core/batch-actions/batch-actions.http.ts | 4 +- .../batch-actions.integration.spec.ts | 53 +++++++++++++------ .../batch-actions/batch-actions.models.ts | 2 - .../batch-actions/batch-actions.reducer.ts | 12 ++--- .../core/batch-actions/batch-actions.store.ts | 4 +- .../store/experiments.effects.spec.ts | 2 +- .../experiments/store/experiments.effects.ts | 1 - .../store/feature-flags.effects.ts | 1 - .../http-interceptors/http-context-tokens.ts | 3 -- .../http-error.interceptor.ts | 7 +-- .../core/segments/store/segments.effects.ts | 1 - .../dashboard/batch-delete-ui.spec.ts | 27 ++++++++++ ...xperiment-root-section-card.component.html | 1 - .../experiment-root-section-card.component.ts | 5 -- ...ture-flag-root-section-card.component.html | 1 - ...eature-flag-root-section-card.component.ts | 5 -- .../segment-root-section-card.component.html | 1 - .../segment-root-section-card.component.ts | 5 -- ...common-batch-selection-status.component.ts | 37 ------------- .../projects/upgrade/src/assets/i18n/en.json | 2 - 23 files changed, 101 insertions(+), 113 deletions(-) delete mode 100644 packages/frontend/projects/upgrade/src/app/shared-standalone-component-lib/components/common-batch-selection-status/common-batch-selection-status.component.ts diff --git a/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.actions.ts b/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.actions.ts index 878982638..52eb71ec0 100644 --- a/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.actions.ts +++ b/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.actions.ts @@ -26,7 +26,7 @@ export function createBatchActions(source: string) { props<{ operationId: string; result: DeletionEligibilityResult | null }>() ), listRequested: createAction(`${prefix} List Requested`, props<{ requestId: string; fromStarting: boolean }>()), - listFailed: createAction(`${prefix} List Failed`, props<{ requestId: string; batchRefresh: boolean }>()), + listFailed: createAction(`${prefix} List Failed`, props<{ requestId: string }>()), }; } diff --git a/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.effects.ts b/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.effects.ts index a0d5e3dfa..77eaf8221 100644 --- a/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.effects.ts +++ b/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.effects.ts @@ -12,7 +12,7 @@ import { throwIfEmpty, withLatestFrom, } from 'rxjs/operators'; -import { BatchDeleteResult, DeletionEligibilityResult } from 'upgrade_types'; +import { BatchDeleteResult, DeletionEligibilityResult, DeletionReasonCode } from 'upgrade_types'; import { RootBatchActions } from './batch-actions.actions'; import { RootBatchState, newBatchRequestId } from './batch-actions.models'; import { validateBatchResponse, validateEligibilityResponse } from './batch-actions.helpers'; @@ -49,7 +49,22 @@ export function batchDeleteEffect( }) ), catchError((error) => - of(actions.batchDeleteRequestFailed({ operationId: snapshot.operationId, status: error?.status || 0 })) + of( + error?.status !== undefined + ? actions.batchDeleteRequestFailed({ operationId: snapshot.operationId, status: error.status }) + : actions.batchDeleteCompleted({ + operationId: snapshot.operationId, + // An invalid/empty successful response has no HTTP error for the interceptor to report. + result: { + phase: 'executed', + results: ids.map((id) => ({ + id, + outcome: 'unknown', + reasonCode: DeletionReasonCode.OUTCOME_UNKNOWN, + })), + }, + }) + ) ), // Logout/user replacement discards session state. Navigation alone never cancels this request. takeUntil(state$.pipe(filter((current) => current.userEmail !== state.userEmail || !current.operation))) @@ -65,9 +80,7 @@ export function reconcileBatchEffect( data: BatchDataSource ) { return events.pipe( - filter((action) => - [actions.batchDeleteCompleted.type, actions.batchDeleteRequestFailed.type].some((type) => type === action.type) - ), + filter((action) => action.type === actions.batchDeleteCompleted.type), withLatestFrom(state$), filter( ([action, state]) => @@ -112,7 +125,8 @@ export function batchFinishedEffect( distinctUntilChanged( (previous, current) => previous[1].operation.snapshot.operationId === current[1].operation.snapshot.operationId ), - switchMap(([, state]) => finish(state)) + // HTTP failures already use the same error notification as single deletion. Do not refresh or notify twice. + switchMap(([, state]) => (state.operation.transportStatus !== undefined ? [] : finish(state))) ); } @@ -122,7 +136,6 @@ export function trackedListRequest( actions: RootBatchActions, dispatch: (action: Action) => void, fromStarting: boolean, - batchRefresh: boolean, request: () => Observable, success: (data: T, requestId: string) => Action[], failure: () => Action[] @@ -132,7 +145,7 @@ export function trackedListRequest( dispatch(actions.listRequested({ requestId, fromStarting })); return request().pipe( switchMap((data) => success(data, requestId)), - catchError(() => concat(of(actions.listFailed({ requestId, batchRefresh })), of(...failure()))), + catchError(() => concat(of(actions.listFailed({ requestId })), of(...failure()))), // NgRx queues a nested dispatch until the current action finishes. Observe our start before // treating a different token (including null on deletion/logout) as cancellation. takeUntil( diff --git a/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.http.spec.ts b/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.http.spec.ts index d8b01ae88..ad54d6297 100644 --- a/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.http.spec.ts +++ b/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.http.spec.ts @@ -8,7 +8,6 @@ import { FeatureFlagsDataService } from '../feature-flags/feature-flags.data.ser import { SegmentsDataService } from '../segments/segments.data.service'; import { HttpCancelInterceptor, SKIP_NAVIGATION_CANCEL } from '../http-interceptors/http-cancel.interceptor'; import { HttpErrorInterceptor } from '../http-interceptors/http-error.interceptor'; -import { HANDLES_BATCH_ERRORS_CONTEXTUALLY } from '../http-interceptors/http-context-tokens'; import { batchHttpContext } from './batch-actions.http'; describe('Batch HTTP contracts', () => { @@ -40,7 +39,6 @@ describe('Batch HTTP contracts', () => { const read = http.expectOne(`/${entity}/deletion-eligibility`); expect(read.request.method).toBe('POST'); expect(read.request.body).toEqual({ ids }); - expect(read.request.context.get(HANDLES_BATCH_ERRORS_CONTEXTUALLY)).toBe(true); expect(read.request.context.get(SKIP_NAVIGATION_CANCEL)).toBe(true); read.flush({ items: [], allDeletable: false }); service.batchDelete(ids).subscribe(); @@ -69,7 +67,7 @@ describe('Batch HTTP contracts', () => { navigation.complete(); }); - it.each([0, 400, 403, 404, 500, 504])('lets the batch UI handle HTTP %i without a duplicate popup', (status) => { + it.each([0, 400, 403, 404, 500, 504])('reports HTTP %i using the existing error popup', (status) => { const notification = { create: jest.fn() }; const auth = { authLogout: jest.fn() }; const interceptor = new HttpErrorInterceptor(auth as any, notification as any, {} as any); @@ -80,11 +78,12 @@ describe('Batch HTTP contracts', () => { handle: () => throwError(() => error), }) .subscribe({ error: failed }); - expect(notification.create).not.toHaveBeenCalled(); + expect(notification.create).toHaveBeenCalledTimes(1); + expect(notification.create.mock.calls[0][0]).toBe('Network call failed. See console for details.'); expect(failed).toHaveBeenCalledWith(error); }); - it('preserves automatic logout for a contextual 401', () => { + it('preserves automatic logout for a batch 401', () => { const auth = { authLogout: jest.fn() }; const interceptor = new HttpErrorInterceptor(auth as any, { create: jest.fn() } as any, {} as any); interceptor diff --git a/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.http.ts b/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.http.ts index c6e97e04e..fee629e53 100644 --- a/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.http.ts +++ b/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.http.ts @@ -1,6 +1,4 @@ import { HttpContext } from '@angular/common/http'; import { SKIP_NAVIGATION_CANCEL } from '../http-interceptors/http-cancel.interceptor'; -import { HANDLES_BATCH_ERRORS_CONTEXTUALLY } from '../http-interceptors/http-context-tokens'; -export const batchHttpContext = () => - new HttpContext().set(SKIP_NAVIGATION_CANCEL, true).set(HANDLES_BATCH_ERRORS_CONTEXTUALLY, true); +export const batchHttpContext = () => new HttpContext().set(SKIP_NAVIGATION_CANCEL, true); diff --git a/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.integration.spec.ts b/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.integration.spec.ts index 2b342cd54..1631e529f 100644 --- a/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.integration.spec.ts +++ b/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.integration.spec.ts @@ -369,13 +369,13 @@ describe.each(fixtures)('$entity batch store/effects integration', (config) => { expect(notifications.showSuccess).toHaveBeenCalledWith(`${count} ${noun}${count === 1 ? '' : 's'} deleted.`); }); - it('reconciles a lost response with one bulk read, counts absence separately, and never retries deletion', () => { + it('reconciles an explicit unknown server result with one bulk read and never retries deletion', () => { selectRows(); const snapshot = prepare(); const reconcile = new Subject(); data.checkDeletionEligibility.mockReturnValueOnce(reconcile); store.dispatch(actions.batchDeleteRequested({ snapshot })); - response.error({ status: 0 }); + response.next({ phase: 'executed', results: rows.map(({ id }) => ({ id, outcome: 'unknown' })) }); expect(batch().operation.status).toBe('reconciling'); expect(Object.keys(batch().selectedById)).toHaveLength(3); reconcile.next( @@ -394,19 +394,35 @@ describe.each(fixtures)('$entity batch store/effects integration', (config) => { expect(notifications.showWarning).toHaveBeenCalledTimes(1); }); - it('retains all unknown items if reconciliation fails and distinguishes a subsequent list-refresh failure', () => { - selectRows(); - const snapshot = prepare(); - data.checkDeletionEligibility.mockReturnValue(throwError(() => new Error('metadata unavailable'))); - data[config.fetchMethod].mockReturnValue(throwError(() => new Error('list unavailable'))); - store.dispatch(actions.batchDeleteRequested({ snapshot })); - response.error({ status: 504 }); - expect(batch().operation.status).toBe('complete'); - expect(batch().operation.reconciliationFailed).toBe(true); - expect(batch().listRefreshFailed).toBe(true); - expect(Object.keys(batch().selectedById)).toHaveLength(3); - expect(data.batchDelete).toHaveBeenCalledTimes(1); - }); + it.each([0, 400, 403, 500, 504])( + 'releases selection after HTTP %i without another request or result snackbar', + (status) => { + selectRows(); + const snapshot = prepare(); + const fetchCount = data[config.fetchMethod].mock.calls.length; + const beforeRows = currentRows(); + const loadedIds = [...batch().loadedIds]; + store.dispatch(actions.batchDeleteRequested({ snapshot })); + response.error({ status }); + expect(batch().operation.status).toBe('complete'); + expect(currentRows()).toEqual(beforeRows); + expect(batch().loadedIds).toEqual(loadedIds); + expect(data.checkDeletionEligibility).not.toHaveBeenCalled(); + expect(data[config.fetchMethod]).toHaveBeenCalledTimes(fetchCount); + expect(notifications.showWarning).not.toHaveBeenCalled(); + expect(notifications.showSuccess).not.toHaveBeenCalled(); + expect(Object.keys(batch().selectedById)).toHaveLength(3); + expect(data.batchDelete).toHaveBeenCalledTimes(1); + store.dispatch(actions.toggleRow({ item: selectionItem(rows[0]) })); + expect(batch().selectedById[rows[0].id]).toBeUndefined(); + store.dispatch(actions.toggleRow({ item: selectionItem(rows[0]) })); + expect(batch().selectedById[rows[0].id]).toBeDefined(); + store.dispatch(actions.toggleHeader({ items: rows.map(selectionItem) })); + expect(Object.keys(batch().selectedById)).toHaveLength(0); + store.dispatch(actions.toggleHeader({ items: rows.map(selectionItem) })); + expect(Object.keys(batch().selectedById)).toHaveLength(3); + } + ); it('reports a preflight rejection without removing any selected or hidden row', () => { selectRows(); @@ -437,11 +453,14 @@ describe.each(fixtures)('$entity batch store/effects integration', (config) => { ); expect(batch().selectedById[rows[0].id]).toBeUndefined(); expect(batch().operation.result.results[0].outcome).toBe('deleted'); - expect(batch().listRefreshFailed).toBe(true); + expect(batch().listLoading).toBe(false); + expect(batch().loadedIds).toEqual([rows[1].id, rows[2].id]); + store.dispatch(actions.toggleRow({ item: selectionItem(rows[1]) })); + expect(batch().selectedById[rows[1].id]).toBeDefined(); expect(notifications.showWarning).toHaveBeenCalledTimes(1); }); - it('treats a malformed successful response as unknown and reconciles instead of retrying', () => { + it('retains unknown items after a malformed response without retrying deletion', () => { selectRows(); const snapshot = prepare(); store.dispatch(actions.batchDeleteRequested({ snapshot })); diff --git a/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.models.ts b/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.models.ts index 4f92b1784..30757eb78 100644 --- a/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.models.ts +++ b/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.models.ts @@ -20,7 +20,6 @@ export interface RootBatchState { loadedIds: string[]; listRequestId: string | null; listLoading: boolean; - listRefreshFailed: boolean; removedIds: string[]; confirmation: BatchDeleteSnapshot | null; operation: { @@ -41,7 +40,6 @@ export const initialRootBatchState: RootBatchState = { loadedIds: [], listRequestId: null, listLoading: false, - listRefreshFailed: false, removedIds: [], confirmation: null, operation: null, diff --git a/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.reducer.ts b/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.reducer.ts index 94c152634..aac8fcd2b 100644 --- a/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.reducer.ts +++ b/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.reducer.ts @@ -49,7 +49,6 @@ export function receiveListRows( selectedById, loadedIds: [...new Set([...(fromStarting ? [] : state.loadedIds), ...rows.map((item) => item.id)])], listLoading: false, - listRefreshFailed: false, }; } @@ -86,14 +85,12 @@ export function reduceRootBatch( return { ...state, listRequestId: action.requestId, - loadedIds: action.fromStarting ? [] : state.loadedIds, + // Keep IDs for displayed rows until replacement rows arrive. Query changes clear them separately. + loadedIds: state.loadedIds, listLoading: true, - listRefreshFailed: false, }; if (matches(action, actions.listFailed)) - return action.requestId === state.listRequestId - ? { ...state, listLoading: false, listRefreshFailed: action.batchRefresh } - : state; + return action.requestId === state.listRequestId ? { ...state, listLoading: false } : state; if (matches(action, actions.confirmedRemoved)) return removeConfirmed(state, action.ids); // Navigation clears the UI selection, but keeps any submitted operation and its result tracking alive. if (matches(action, actions.rootPageLeft)) return { ...invalidateSelection(state), selectedById: {} }; @@ -190,7 +187,8 @@ export function reduceRootBatch( operation: { ...state.operation, transportStatus: action.status, - status: rejected ? 'complete' : 'reconciling', + // The shared HTTP interceptor reports request errors. Release controls without follow-up requests. + status: 'complete', result: { phase: rejected ? 'rejected' : 'executed', results: state.operation.snapshot.items.map(({ id }) => ({ diff --git a/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.store.ts b/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.store.ts index ddacfc7ca..4dba8a373 100644 --- a/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.store.ts +++ b/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.store.ts @@ -77,7 +77,9 @@ export function withRootBatch( } if ( rootBatch.removedIds.length > state.rootBatch.removedIds.length || - (isBatchBusy(state.rootBatch) && rootBatch.operation?.status === 'complete') + (isBatchBusy(state.rootBatch) && + rootBatch.operation?.status === 'complete' && + rootBatch.operation.transportStatus === undefined) ) { result = { ...result, [config.skipKey]: 0, [config.totalKey]: null }; } 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 9964b0c5c..38f4720cf 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 @@ -135,7 +135,7 @@ describe('ExperimentEffects', () => { const subscription = service.getPaginatedExperiment$.subscribe((result) => results.push(result)); actions$.next(actionGetExperiments({})); expect(results).toEqual([ - batchActions.listFailed({ requestId: expect.any(String), batchRefresh: false }), + batchActions.listFailed({ requestId: expect.any(String) }), actionGetExperimentsFailure({ error: null }), ]); subscription.unsubscribe(); 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 d1562e23a..767287ffc 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 @@ -120,7 +120,6 @@ export class ExperimentEffects { experimentAction.batchActions, (event) => this.store$.dispatch(event), fromStarting, - !!action.batchRefresh, () => { this.store$.dispatch(experimentAction.actionSetIsLoadingExperiment({ isLoadingExperiment: true })); return this.experimentDataService.getAllExperiment(params, !!action.batchRefresh); 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 101e23ff1..55f5b04cc 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 @@ -99,7 +99,6 @@ export class FeatureFlagsEffects { FeatureFlagsActions.batchActions, (event) => this.store$.dispatch(event), fromStarting, - !!action.batchRefresh, () => { this.store$.dispatch(FeatureFlagsActions.actionSetIsLoadingFeatureFlags({ isLoadingFeatureFlags: true })); return this.featureFlagsDataService.fetchFeatureFlagsPaginated(params, !!action.batchRefresh); diff --git a/packages/frontend/projects/upgrade/src/app/core/http-interceptors/http-context-tokens.ts b/packages/frontend/projects/upgrade/src/app/core/http-interceptors/http-context-tokens.ts index 57616cee8..4ba196e66 100644 --- a/packages/frontend/projects/upgrade/src/app/core/http-interceptors/http-context-tokens.ts +++ b/packages/frontend/projects/upgrade/src/app/core/http-interceptors/http-context-tokens.ts @@ -5,6 +5,3 @@ import { HttpContextToken } from '@angular/common/http'; // Kept in its own file so data services can import it without pulling in the // interceptor's dependency graph (AuthService -> CoreModule), which is circular. export const HANDLES_404_CONTEXTUALLY = new HttpContextToken(() => false); - -// Batch results and metadata checks own their error UI; 401 must still log the user out. -export const HANDLES_BATCH_ERRORS_CONTEXTUALLY = new HttpContextToken(() => false); diff --git a/packages/frontend/projects/upgrade/src/app/core/http-interceptors/http-error.interceptor.ts b/packages/frontend/projects/upgrade/src/app/core/http-interceptors/http-error.interceptor.ts index ceb0fd3dc..eb646c2d1 100755 --- a/packages/frontend/projects/upgrade/src/app/core/http-interceptors/http-error.interceptor.ts +++ b/packages/frontend/projects/upgrade/src/app/core/http-interceptors/http-error.interceptor.ts @@ -6,7 +6,7 @@ import { catchError } from 'rxjs/operators'; import { ENV, Environment } from '../../../environments/environment-types'; import { AuthService } from '../auth/auth.service'; import { SERVER_ERROR } from 'upgrade_types'; -import { HANDLES_404_CONTEXTUALLY, HANDLES_BATCH_ERRORS_CONTEXTUALLY } from './http-context-tokens'; +import { HANDLES_404_CONTEXTUALLY } from './http-context-tokens'; @Injectable() export class HttpErrorInterceptor implements HttpInterceptor { @@ -34,10 +34,7 @@ export class HttpErrorInterceptor implements HttpInterceptor { if (err.status === 401) { // auto logout if 401 response returned from api this.authService.authLogout(); - } else if ( - !request.context?.get(HANDLES_BATCH_ERRORS_CONTEXTUALLY) && - !(err.status === 404 && request.context?.get(HANDLES_404_CONTEXTUALLY)) - ) { + } else if (!(err.status === 404 && request.context?.get(HANDLES_404_CONTEXTUALLY))) { this.openPopup(err); } // re-throw to allow the error to be caught by the calling code 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 b1fc5b014..43d965423 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 @@ -96,7 +96,6 @@ export class SegmentsEffects { SegmentsActions.batchActions, (event) => this.store$.dispatch(event), fromStarting, - !!action.batchRefresh, () => { this.store$.dispatch(SegmentsActions.actionSetIsLoadingSegments({ isLoadingSegments: true })); return this.segmentsDataService.fetchSegmentsPaginated(params, !!action.batchRefresh); diff --git a/packages/frontend/projects/upgrade/src/app/features/dashboard/batch-delete-ui.spec.ts b/packages/frontend/projects/upgrade/src/app/features/dashboard/batch-delete-ui.spec.ts index 294b2c081..23b57dc0e 100644 --- a/packages/frontend/projects/upgrade/src/app/features/dashboard/batch-delete-ui.spec.ts +++ b/packages/frontend/projects/upgrade/src/app/features/dashboard/batch-delete-ui.spec.ts @@ -406,6 +406,33 @@ describe.each(cases)('$entity root batch UI', (config) => { expect(trigger.disabled).toBe(config.entity !== 'segments'); }); + it('keeps checkboxes usable without a banner or reload button after request and refresh failures', () => { + selectFirst(); + store.dispatch(actions.prepareConfirmation({ operationId: 'offline-delete' })); + store.dispatch(actions.batchDeleteRequested({ snapshot: batch().confirmation })); + fixture.detectChanges(); + expect(checkboxes()[1].disabled).toBe(true); + store.dispatch(actions.batchDeleteRequestFailed({ operationId: 'offline-delete', status: 0 })); + fixture.detectChanges(); + expect(checkboxes().every((input) => !input.disabled)).toBe(true); + checkboxes()[1].click(); + fixture.detectChanges(); + expect(Object.keys(batch().selectedById)).toHaveLength(0); + checkboxes()[1].click(); + fixture.detectChanges(); + expect(Object.keys(batch().selectedById)).toHaveLength(1); + + store.dispatch(actions.listRequested({ requestId: 'failed-refresh', fromStarting: true })); + store.dispatch(actions.listFailed({ requestId: 'failed-refresh' })); + fixture.detectChanges(); + expect(fixture.nativeElement.querySelector('app-common-batch-selection-status')).toBeNull(); + expect(fixture.nativeElement.querySelector('.selection-status')).toBeNull(); + expect(fixture.nativeElement.textContent).not.toContain('Reload list'); + checkboxes()[2].click(); + fixture.detectChanges(); + expect(Object.keys(batch().selectedById)).toHaveLength(2); + }); + it('explains a hidden restriction only in the menu tooltip without inserting a status row', () => { load([ { diff --git a/packages/frontend/projects/upgrade/src/app/features/dashboard/experiments/pages/experiment-root-page/experiment-root-page-content/experiment-root-section-card/experiment-root-section-card.component.html b/packages/frontend/projects/upgrade/src/app/features/dashboard/experiments/pages/experiment-root-page/experiment-root-page-content/experiment-root-section-card/experiment-root-section-card.component.html index cadc2aced..22d89d734 100644 --- a/packages/frontend/projects/upgrade/src/app/features/dashboard/experiments/pages/experiment-root-page/experiment-root-page-content/experiment-root-section-card/experiment-root-section-card.component.html +++ b/packages/frontend/projects/upgrade/src/app/features/dashboard/experiments/pages/experiment-root-page/experiment-root-page-content/experiment-root-section-card/experiment-root-section-card.component.html @@ -33,7 +33,6 @@ @if (isSectionCardExpanded) { - @if (!(isInitialLoading$ | async) && !batchView?.selectedCount) {
diff --git a/packages/frontend/projects/upgrade/src/app/features/dashboard/experiments/pages/experiment-root-page/experiment-root-page-content/experiment-root-section-card/experiment-root-section-card.component.ts b/packages/frontend/projects/upgrade/src/app/features/dashboard/experiments/pages/experiment-root-page/experiment-root-page-content/experiment-root-section-card/experiment-root-section-card.component.ts index fcce2cdf6..38ac72e44 100644 --- a/packages/frontend/projects/upgrade/src/app/features/dashboard/experiments/pages/experiment-root-page/experiment-root-page-content/experiment-root-section-card/experiment-root-section-card.component.ts +++ b/packages/frontend/projects/upgrade/src/app/features/dashboard/experiments/pages/experiment-root-page/experiment-root-page-content/experiment-root-section-card/experiment-root-section-card.component.ts @@ -1,6 +1,5 @@ import { ChangeDetectionStrategy, Component, ViewChild } from '@angular/core'; import { RootBatchActionsDirective } from '../../../../../../../shared/directives/root-batch-actions.directive'; -import { CommonBatchSelectionStatusComponent } from '@shared-component-lib/common-batch-selection-status/common-batch-selection-status.component'; import { CommonSectionCardComponent, CommonSectionCardSearchHeaderComponent, @@ -26,7 +25,6 @@ import { StratificationFactorsService } from '../../../../../../../core/stratifi selector: 'app-experiment-root-section-card', imports: [ RootBatchActionsDirective, - CommonBatchSelectionStatusComponent, CommonSectionCardComponent, CommonSectionCardSearchHeaderComponent, CommonSectionCardActionButtonsComponent, @@ -44,9 +42,6 @@ export class ExperimentRootSectionCardComponent { readonly batch = this.experimentService.batch; @ViewChild(RootBatchActionsDirective) batchUi: RootBatchActionsDirective; - reloadBatchList() { - this.experimentService.loadExperiments(true); - } experiments$ = this.experimentService.experiments$; isLoadingExperiments$ = this.experimentService.isLoadingExperiment$; isInitialLoading$ = this.experimentService.haveInitialExperimentsLoaded(); diff --git a/packages/frontend/projects/upgrade/src/app/features/dashboard/feature-flags/pages/feature-flag-root-page/feature-flag-root-page-content/feature-flag-root-section-card/feature-flag-root-section-card.component.html b/packages/frontend/projects/upgrade/src/app/features/dashboard/feature-flags/pages/feature-flag-root-page/feature-flag-root-page-content/feature-flag-root-section-card/feature-flag-root-section-card.component.html index e607033b8..b0f10524f 100644 --- a/packages/frontend/projects/upgrade/src/app/features/dashboard/feature-flags/pages/feature-flag-root-page/feature-flag-root-page-content/feature-flag-root-section-card/feature-flag-root-section-card.component.html +++ b/packages/frontend/projects/upgrade/src/app/features/dashboard/feature-flags/pages/feature-flag-root-page/feature-flag-root-page-content/feature-flag-root-section-card/feature-flag-root-section-card.component.html @@ -33,7 +33,6 @@ @if (isSectionCardExpanded) { - @if (!(isInitialLoading$ | async) && !batchView?.selectedCount) {
diff --git a/packages/frontend/projects/upgrade/src/app/features/dashboard/feature-flags/pages/feature-flag-root-page/feature-flag-root-page-content/feature-flag-root-section-card/feature-flag-root-section-card.component.ts b/packages/frontend/projects/upgrade/src/app/features/dashboard/feature-flags/pages/feature-flag-root-page/feature-flag-root-page-content/feature-flag-root-section-card/feature-flag-root-section-card.component.ts index f35f21768..788292977 100644 --- a/packages/frontend/projects/upgrade/src/app/features/dashboard/feature-flags/pages/feature-flag-root-page/feature-flag-root-page-content/feature-flag-root-section-card/feature-flag-root-section-card.component.ts +++ b/packages/frontend/projects/upgrade/src/app/features/dashboard/feature-flags/pages/feature-flag-root-page/feature-flag-root-page-content/feature-flag-root-section-card/feature-flag-root-section-card.component.ts @@ -1,6 +1,5 @@ import { ChangeDetectionStrategy, Component, ViewChild } from '@angular/core'; import { RootBatchActionsDirective } from '../../../../../../../shared/directives/root-batch-actions.directive'; -import { CommonBatchSelectionStatusComponent } from '@shared-component-lib/common-batch-selection-status/common-batch-selection-status.component'; import { CommonSectionCardComponent, CommonSectionCardSearchHeaderComponent, @@ -27,7 +26,6 @@ import { AuthService } from '../../../../../../../core/auth/auth.service'; selector: 'app-feature-flag-root-section-card', imports: [ RootBatchActionsDirective, - CommonBatchSelectionStatusComponent, CommonSectionCardComponent, CommonSectionCardSearchHeaderComponent, CommonSectionCardActionButtonsComponent, @@ -46,9 +44,6 @@ export class FeatureFlagRootSectionCardComponent { readonly batch = this.featureFlagService.batch; @ViewChild(RootBatchActionsDirective) batchUi: RootBatchActionsDirective; - reloadBatchList() { - this.featureFlagService.fetchFeatureFlags(true); - } featureFlags$: Observable = this.featureFlagService.featureFlags$; isLoadingFeatureFlags$ = this.featureFlagService.isLoadingFeatureFlags$; isInitialLoading$ = this.featureFlagService.isInitialFeatureFlagsLoading$; diff --git a/packages/frontend/projects/upgrade/src/app/features/dashboard/segments/pages/segment-root-page/segment-root-page-content/segment-root-section-card/segment-root-section-card.component.html b/packages/frontend/projects/upgrade/src/app/features/dashboard/segments/pages/segment-root-page/segment-root-page-content/segment-root-section-card/segment-root-section-card.component.html index 3dcc132d9..bfac77750 100644 --- a/packages/frontend/projects/upgrade/src/app/features/dashboard/segments/pages/segment-root-page/segment-root-page-content/segment-root-section-card/segment-root-section-card.component.html +++ b/packages/frontend/projects/upgrade/src/app/features/dashboard/segments/pages/segment-root-page/segment-root-page-content/segment-root-section-card/segment-root-section-card.component.html @@ -33,7 +33,6 @@ @if (isSectionCardExpanded) { - @if (!(isInitialLoading$ | async) && !batchView?.selectedCount) {
diff --git a/packages/frontend/projects/upgrade/src/app/features/dashboard/segments/pages/segment-root-page/segment-root-page-content/segment-root-section-card/segment-root-section-card.component.ts b/packages/frontend/projects/upgrade/src/app/features/dashboard/segments/pages/segment-root-page/segment-root-page-content/segment-root-section-card/segment-root-section-card.component.ts index 861cc1f71..32c7b8d47 100644 --- a/packages/frontend/projects/upgrade/src/app/features/dashboard/segments/pages/segment-root-page/segment-root-page-content/segment-root-section-card/segment-root-section-card.component.ts +++ b/packages/frontend/projects/upgrade/src/app/features/dashboard/segments/pages/segment-root-page/segment-root-page-content/segment-root-section-card/segment-root-section-card.component.ts @@ -1,6 +1,5 @@ import { ChangeDetectionStrategy, Component, ViewChild } from '@angular/core'; import { RootBatchActionsDirective } from '../../../../../../../shared/directives/root-batch-actions.directive'; -import { CommonBatchSelectionStatusComponent } from '@shared-component-lib/common-batch-selection-status/common-batch-selection-status.component'; import { CommonSectionCardComponent, CommonSectionCardSearchHeaderComponent, @@ -24,7 +23,6 @@ import { AuthService } from '../../../../../../../core/auth/auth.service'; selector: 'app-segment-root-section-card', imports: [ RootBatchActionsDirective, - CommonBatchSelectionStatusComponent, CommonSectionCardComponent, CommonSectionCardSearchHeaderComponent, CommonSectionCardActionButtonsComponent, @@ -44,9 +42,6 @@ export class SegmentRootSectionCardComponent { readonly batch = this.segmentsService.batch; @ViewChild(RootBatchActionsDirective) batchUi: RootBatchActionsDirective; - reloadBatchList() { - this.segmentsService.fetchSegmentsPaginated(true); - } segments$ = this.segmentsService.selectAllSegments$; isLoadingSegments$ = this.segmentsService.isLoadingSegments$; isInitialLoading$ = this.segmentsService.isInitialSegmentsLoading(); diff --git a/packages/frontend/projects/upgrade/src/app/shared-standalone-component-lib/components/common-batch-selection-status/common-batch-selection-status.component.ts b/packages/frontend/projects/upgrade/src/app/shared-standalone-component-lib/components/common-batch-selection-status/common-batch-selection-status.component.ts deleted file mode 100644 index 2179f238b..000000000 --- a/packages/frontend/projects/upgrade/src/app/shared-standalone-component-lib/components/common-batch-selection-status/common-batch-selection-status.component.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { ChangeDetectionStrategy, Component, EventEmitter, Input, Output } from '@angular/core'; -import { MatButtonModule } from '@angular/material/button'; -import { TranslateModule } from '@ngx-translate/core'; -import type { RootBatchView } from '../../../shared/directives/root-batch-actions.directive'; - -@Component({ - selector: 'app-common-batch-selection-status', - imports: [MatButtonModule, TranslateModule], - changeDetection: ChangeDetectionStrategy.OnPush, - template: ` - @if (view?.state.listRefreshFailed) { - - } - `, - styles: [ - ` - .selection-status { - display: flex; - align-items: center; - flex-wrap: wrap; - gap: 8px; - padding: 12px 32px 0; - color: var(--dark-grey); - font-size: 14px; - } - `, - ], -}) -export class CommonBatchSelectionStatusComponent { - @Input() view: RootBatchView; - @Output() reload = new EventEmitter(); -} diff --git a/packages/frontend/projects/upgrade/src/assets/i18n/en.json b/packages/frontend/projects/upgrade/src/assets/i18n/en.json index a605b16c2..9e504692e 100644 --- a/packages/frontend/projects/upgrade/src/assets/i18n/en.json +++ b/packages/frontend/projects/upgrade/src/assets/i18n/en.json @@ -9,8 +9,6 @@ "batch-delete.selection.flags.row": "Select feature flag {{name}}", "batch-delete.selection.segments.row": "Select segment {{name}}", "batch-delete.selection.busy": "Deletion is in progress. Please wait for the result.", - "batch-delete.selection.list-refresh-failed": "The list could not be refreshed. Reload it to see its current contents.", - "batch-delete.selection.reload": "Reload list", "batch-delete.dialog.experiments.title": "Delete Experiments", "batch-delete.dialog.experiments.one": "Are you sure you want to delete 1 experiment?", "batch-delete.dialog.experiments.other": "Are you sure you want to delete {{count}} experiments?", From 9a6420987e5368c4f3ccf7f21b24ec69d77f332f Mon Sep 17 00:00:00 2001 From: Zack Lee Date: Fri, 11 Sep 2026 13:42:21 -0400 Subject: [PATCH 09/61] refactor: remove obsolete batch deletion code and simplify regression tests --- .../api/controllers/ExperimentController.ts | 2 +- .../api/controllers/FeatureFlagController.ts | 2 +- .../src/api/controllers/SegmentController.ts | 2 +- .../test/integration/BatchDelete/index.ts | 8 ------- .../integration/DeletionEligibility/index.ts | 9 ++++--- .../batch-actions/batch-actions.actions.ts | 3 +-- .../batch-actions/batch-actions.effects.ts | 3 +-- .../batch-actions/batch-actions.facade.ts | 1 - .../batch-actions/batch-actions.helpers.ts | 4 ---- .../batch-actions.integration.spec.ts | 7 +++--- .../batch-actions/batch-actions.models.ts | 5 +--- .../batch-actions/batch-actions.reducer.ts | 24 +++---------------- .../experiments/store/experiments.effects.ts | 4 +--- .../store/feature-flags.effects.ts | 5 +--- .../http-error.interceptor.ts | 2 +- .../core/segments/store/segments.effects.ts | 7 ++---- .../dashboard/batch-delete-ui.spec.ts | 2 +- .../batch-confirmation.spec.ts | 13 ---------- .../root-batch-actions.directive.ts | 3 +-- .../projects/upgrade/src/assets/i18n/en.json | 7 ------ 20 files changed, 26 insertions(+), 87 deletions(-) diff --git a/packages/backend/src/api/controllers/ExperimentController.ts b/packages/backend/src/api/controllers/ExperimentController.ts index 66f46c376..3f253aeed 100644 --- a/packages/backend/src/api/controllers/ExperimentController.ts +++ b/packages/backend/src/api/controllers/ExperimentController.ts @@ -713,7 +713,7 @@ export class ExperimentController { * /experiments/batch-delete: * post: * summary: Delete the selected experiments - * description: Checks the entire selection before mutation, then deletes sequentially. Stops after the first failure and returns one result per ID. + * description: Checks the selection, skips missing or ineligible items, and deletes eligible items sequentially. Other execution failures stop the remaining items. Returns one result per ID. * tags: * - Experiments * parameters: diff --git a/packages/backend/src/api/controllers/FeatureFlagController.ts b/packages/backend/src/api/controllers/FeatureFlagController.ts index f3bf01efb..f69c4f978 100644 --- a/packages/backend/src/api/controllers/FeatureFlagController.ts +++ b/packages/backend/src/api/controllers/FeatureFlagController.ts @@ -205,7 +205,7 @@ export class FeatureFlagsController { * /flags/batch-delete: * post: * summary: Delete the selected flags - * description: Checks the entire selection before mutation, then deletes sequentially. Stops after the first failure and returns one result per ID. + * description: Checks the selection, skips missing or ineligible items, and deletes eligible items sequentially. Other execution failures stop the remaining items. Returns one result per ID. * tags: * - Feature Flags * parameters: diff --git a/packages/backend/src/api/controllers/SegmentController.ts b/packages/backend/src/api/controllers/SegmentController.ts index d4b22a87b..b2798cc55 100644 --- a/packages/backend/src/api/controllers/SegmentController.ts +++ b/packages/backend/src/api/controllers/SegmentController.ts @@ -352,7 +352,7 @@ export class SegmentController { * /segments/batch-delete: * post: * summary: Delete the selected segments - * description: Checks the entire selection before mutation, then deletes sequentially. Stops after the first failure and returns one result per ID. + * description: Checks the selection, skips missing or ineligible items, and deletes eligible items sequentially. Other execution failures stop the remaining items. Returns one result per ID. * tags: * - Segment * parameters: diff --git a/packages/backend/test/integration/BatchDelete/index.ts b/packages/backend/test/integration/BatchDelete/index.ts index c94485ab3..0106b833c 100644 --- a/packages/backend/test/integration/BatchDelete/index.ts +++ b/packages/backend/test/integration/BatchDelete/index.ts @@ -1,5 +1,4 @@ import { randomUUID } from 'crypto'; -import { performance } from 'perf_hooks'; import { Application } from 'express'; import request from 'supertest'; import Container from 'typedi'; @@ -597,17 +596,10 @@ export function registerBatchDeleteTests(connections: () => [DataSource, DataSou const rows = await create(entity, count); const ids = rows.map((row) => row.id).reverse(); const status = jest.spyOn(Container.get(SegmentService), 'getSegmentStatus'); - const queries = jest.spyOn(db.logger, 'logQuery'); - const start = performance.now(); const { body } = await request(app).post(route(entity)).send({ ids }).expect(200); - const durationMs = Math.round(performance.now() - start); - const queryCount = queries.mock.calls.length; expect(body).toEqual({ phase: 'executed', results: ids.map((id) => ({ id, outcome: 'deleted' })) }); expect(status).toHaveBeenCalledTimes(entity === 'segments' ? 1 : 0); expect(await db.getRepository(model[entity]).countBy({ id: In(ids) })).toBe(0); - // Opt-in local measurement; keep CI output quiet and avoid machine-dependent timing assertions. - if (process.env.BATCH_DELETE_MEASURE === 'true') - console.info(JSON.stringify({ entity, count, durationMs, queryCount })); } ); }); diff --git a/packages/backend/test/integration/DeletionEligibility/index.ts b/packages/backend/test/integration/DeletionEligibility/index.ts index dfe34d07a..6dd88316b 100644 --- a/packages/backend/test/integration/DeletionEligibility/index.ts +++ b/packages/backend/test/integration/DeletionEligibility/index.ts @@ -315,7 +315,7 @@ export function registerDeletionEligibilityTests(connections: () => [DataSource, }); test.each(entities.flatMap((entity) => [20, 100, 500].map((count) => ({ entity, count }))))( - '$entity accepts $count IDs in one read-only HTTP request with fixed query count', + '$entity accepts $count IDs in one read-only HTTP request without per-ID queries', async ({ entity, count }) => { const rows = await create(entity, count); const ids = rows.map((row) => row.id).reverse(); @@ -331,8 +331,11 @@ export function registerDeletionEligibilityTests(connections: () => [DataSource, expect(queries.filter((sql) => /^(INSERT|UPDATE|DELETE|ALTER|CREATE)|FOR (UPDATE|SHARE)/i.test(sql))).toEqual( [] ); - // Includes the real auth check's user lookup and, for segments, BEGIN/COMMIT plus the five shared reads. - expect(queries).toHaveLength(entity === 'segments' ? 9 : 2); + // Count selection reads, not background recomputation started by the global-segment seed. + const selectionQueries = querySpy.mock.calls.filter(([, parameters]) => + parameters?.some((parameter) => ids.includes(parameter)) + ); + expect(selectionQueries).toHaveLength(1); expect(statusSpy).toHaveBeenCalledTimes(entity === 'segments' ? 1 : 0); expect(detailsSpy).not.toHaveBeenCalled(); expect(membersSpy).not.toHaveBeenCalled(); diff --git a/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.actions.ts b/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.actions.ts index 52eb71ec0..0677e7c93 100644 --- a/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.actions.ts +++ b/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.actions.ts @@ -7,7 +7,6 @@ export function createBatchActions(source: string) { return { toggleRow: createAction(`${prefix} Toggle Row`, props<{ item: RootSelectionItem }>()), toggleHeader: createAction(`${prefix} Toggle Header`, props<{ items: RootSelectionItem[] }>()), - clearSelection: createAction(`${prefix} Clear Selection`), rootPageLeft: createAction(`${prefix} Root Page Left`), confirmedRemoved: createAction(`${prefix} Confirmed Removed`, props<{ ids: string[] }>()), prepareConfirmation: createAction(`${prefix} Prepare Confirmation`, props<{ operationId: string }>()), @@ -25,7 +24,7 @@ export function createBatchActions(source: string) { `${prefix} Reconciliation Completed`, props<{ operationId: string; result: DeletionEligibilityResult | null }>() ), - listRequested: createAction(`${prefix} List Requested`, props<{ requestId: string; fromStarting: boolean }>()), + listRequested: createAction(`${prefix} List Requested`, props<{ requestId: string }>()), listFailed: createAction(`${prefix} List Failed`, props<{ requestId: string }>()), }; } diff --git a/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.effects.ts b/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.effects.ts index 77eaf8221..4b97d7c9b 100644 --- a/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.effects.ts +++ b/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.effects.ts @@ -135,14 +135,13 @@ export function trackedListRequest( state$: Observable, actions: RootBatchActions, dispatch: (action: Action) => void, - fromStarting: boolean, request: () => Observable, success: (data: T, requestId: string) => Action[], failure: () => Action[] ) { return defer(() => { const requestId = newBatchRequestId(); - dispatch(actions.listRequested({ requestId, fromStarting })); + dispatch(actions.listRequested({ requestId })); return request().pipe( switchMap((data) => success(data, requestId)), catchError(() => concat(of(actions.listFailed({ requestId })), of(...failure()))), diff --git a/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.facade.ts b/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.facade.ts index df169071d..7206a8780 100644 --- a/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.facade.ts +++ b/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.facade.ts @@ -23,7 +23,6 @@ export function createBatchFacade( store .pipe(select(selectRows), take(1)) .subscribe((rows) => store.dispatch(actions.toggleHeader({ items: rows.map(selectionItem) }))), - clear: () => store.dispatch(actions.clearSelection()), leaveRootPage: () => store.dispatch(actions.rootPageLeft()), prepareConfirmation: () => store.dispatch(actions.prepareConfirmation({ operationId: newBatchRequestId() })), dismissConfirmation: () => store.dispatch(actions.dismissConfirmation()), diff --git a/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.helpers.ts b/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.helpers.ts index b0192ce86..69f3682e8 100644 --- a/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.helpers.ts +++ b/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.helpers.ts @@ -29,9 +29,6 @@ export function localDeletionReason( role: RootBatchState['role'] ): DeletionReasonCode | undefined { if (!hasBatchDeletePermission(role, entity)) return DeletionReasonCode.MISSING_PERMISSION; - if (item.availability === 'not_found') return DeletionReasonCode.NOT_FOUND; - if (item.availability === 'unavailable') return item.reasonCode || DeletionReasonCode.ELIGIBILITY_UNAVAILABLE; - if (item.reasonCode) return item.reasonCode; if (entity === 'experiments') return getExperimentDeletionReason(item.stateOrStatus as EXPERIMENT_STATE); if (entity === 'flags') return getFlagDeletionReason(item.stateOrStatus as FEATURE_FLAG_STATUS); if (item.segmentType !== SEGMENT_TYPE.PUBLIC) return DeletionReasonCode.PROTECTED_SEGMENT_TYPE; @@ -113,7 +110,6 @@ export function batchResultCounts(state: RootBatchState) { return { deleted: results.filter((item) => item.outcome === 'deleted').length, absent: absent.size, - remaining: Object.keys(state.selectedById).length, uncertain: results.some((item) => item.outcome === 'unknown') || !!state.operation?.reconciliationFailed, failed: results.filter((item) => ['failed', 'ineligible', 'forbidden'].includes(item.outcome)).length, notAttempted: results.filter((item) => item.outcome === 'not_attempted').length, diff --git a/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.integration.spec.ts b/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.integration.spec.ts index 1631e529f..89b2f326d 100644 --- a/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.integration.spec.ts +++ b/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.integration.spec.ts @@ -274,7 +274,7 @@ describe.each(fixtures)('$entity batch store/effects integration', (config) => { }); expect(batch().selectedById[rows[1].id]).toBeUndefined(); expect(currentRows().some((row) => row.id === rows[1].id)).toBe(false); - expect(batchResultCounts(batch())).toMatchObject({ deleted: 0, absent: 1, remaining: 2 }); + expect(batchResultCounts(batch())).toMatchObject({ deleted: 0, absent: 1 }); expect(notifications.showWarning).toHaveBeenCalledTimes(1); expect(data[config.fetchMethod]).toHaveBeenLastCalledWith(expect.objectContaining({ skip: 0 }), true); expect(batch().confirmation).toBeNull(); @@ -286,7 +286,7 @@ describe.each(fixtures)('$entity batch store/effects integration', (config) => { const snapshot = prepare(); store.dispatch(actions.batchDeleteRequested({ snapshot })); store.dispatch(actions.batchDeleteRequested({ snapshot: { ...snapshot, operationId: 'duplicate' } })); - store.dispatch(actions.clearSelection()); + store.dispatch(actions.toggleHeader({ items: rows.map(selectionItem) })); store.dispatch(config.actions.actionSetSearchString({ searchString: 'latest query' })); expect(data.batchDelete).toHaveBeenCalledTimes(1); expect(data.batchDelete).toHaveBeenCalledWith(rows.map((row) => row.id)); @@ -384,7 +384,8 @@ describe.each(fixtures)('$entity batch store/effects integration', (config) => { [rows[0].id] ) ); - expect(batchResultCounts(batch())).toMatchObject({ deleted: 0, absent: 1, remaining: 2, uncertain: true }); + expect(batchResultCounts(batch())).toMatchObject({ deleted: 0, absent: 1, uncertain: true }); + expect(Object.keys(batch().selectedById)).toHaveLength(2); expect(notifications.showWarning).toHaveBeenCalledWith( '1 item was already absent. Some outcomes could not be confirmed. Check the list before retrying.' ); diff --git a/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.models.ts b/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.models.ts index 30757eb78..647b8d8c7 100644 --- a/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.models.ts +++ b/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.models.ts @@ -1,9 +1,6 @@ import { BatchDeleteResult, DeletionEligibilityItem, UserRole } from 'upgrade_types'; -export type RootSelectionItem = Pick & { - availability?: DeletionEligibilityItem['availability']; - reasonCode?: DeletionEligibilityItem['reasonCode']; -}; +export type RootSelectionItem = Pick; export interface BatchDeleteSnapshot { operationId: string; diff --git a/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.reducer.ts b/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.reducer.ts index aac8fcd2b..a26200689 100644 --- a/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.reducer.ts +++ b/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.reducer.ts @@ -68,18 +68,7 @@ export function reduceRootBatch( const email = action.user?.email || null; const role = action.user?.role || null; if (state.userEmail !== email) return { ...initialRootBatchState, userEmail: email, role }; - return state.role === role - ? state - : { - ...invalidateSelection(state), - role, - selectedById: Object.fromEntries( - Object.entries(state.selectedById).map(([id, item]) => [ - id, - item.reasonCode === DeletionReasonCode.MISSING_PERMISSION ? { ...item, reasonCode: undefined } : item, - ]) - ), - }; + return state.role === role ? state : { ...invalidateSelection(state), role }; } if (matches(action, actions.listRequested)) return { @@ -94,17 +83,10 @@ export function reduceRootBatch( if (matches(action, actions.confirmedRemoved)) return removeConfirmed(state, action.ids); // Navigation clears the UI selection, but keeps any submitted operation and its result tracking alive. if (matches(action, actions.rootPageLeft)) return { ...invalidateSelection(state), selectedById: {} }; - if ( - matches(action, actions.clearSelection) || - matches(action, actions.toggleHeader) || - matches(action, actions.toggleRow) - ) { + if (matches(action, actions.toggleHeader) || matches(action, actions.toggleRow)) { if (isBatchBusy(state)) return state; let selectedById = { ...state.selectedById }; - if ( - matches(action, actions.clearSelection) || - (matches(action, actions.toggleHeader) && Object.keys(selectedById).length) - ) { + if (matches(action, actions.toggleHeader) && Object.keys(selectedById).length) { selectedById = {}; } else { const items = matches(action, actions.toggleRow) 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 767287ffc..451a47a71 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 @@ -13,7 +13,7 @@ import { Actions, createEffect, ofType } from '@ngrx/effects'; import * as experimentAction from './experiments.actions'; import * as analysisActions from '../../analysis/store/analysis.actions'; import { ExperimentDataService } from '../experiments.data.service'; -import { map, filter, switchMap, catchError, tap, withLatestFrom, first, mergeMap, takeUntil } from 'rxjs/operators'; +import { map, filter, switchMap, catchError, tap, withLatestFrom, mergeMap, takeUntil } from 'rxjs/operators'; import { UpsertExperimentType, IExperimentEnrollmentStats, @@ -119,7 +119,6 @@ export class ExperimentEffects { this.store$.pipe(select(selectRootBatch)), experimentAction.batchActions, (event) => this.store$.dispatch(event), - fromStarting, () => { this.store$.dispatch(experimentAction.actionSetIsLoadingExperiment({ isLoadingExperiment: true })); return this.experimentDataService.getAllExperiment(params, !!action.batchRefresh); @@ -832,5 +831,4 @@ export class ExperimentEffects { element.click(); document.body.removeChild(element); } - private getSearchString$ = () => this.store$.pipe(select(selectSearchString)).pipe(first()); } 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 55f5b04cc..1f173c3d2 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 @@ -12,7 +12,7 @@ import { FeatureFlagsDataService } from '../feature-flags.data.service'; import { Actions, createEffect, ofType } from '@ngrx/effects'; import { Injectable } from '@angular/core'; import * as FeatureFlagsActions from './feature-flags.actions'; -import { catchError, switchMap, mergeMap, map, filter, withLatestFrom, tap, first } from 'rxjs/operators'; +import { catchError, switchMap, mergeMap, map, filter, withLatestFrom, tap } from 'rxjs/operators'; import { FeatureFlag, NUMBER_OF_FLAGS } from './feature-flags.model'; import { DATE_RANGE } from '../../experiments/store/experiments.model'; import { Router } from '@angular/router'; @@ -98,7 +98,6 @@ export class FeatureFlagsEffects { this.store$.pipe(select(selectRootBatch)), FeatureFlagsActions.batchActions, (event) => this.store$.dispatch(event), - fromStarting, () => { this.store$.dispatch(FeatureFlagsActions.actionSetIsLoadingFeatureFlags({ isLoadingFeatureFlags: true })); return this.featureFlagsDataService.fetchFeatureFlagsPaginated(params, !!action.batchRefresh); @@ -580,6 +579,4 @@ export class FeatureFlagsEffects { ) ) ); - - private getSearchString$ = () => this.store$.pipe(select(selectSearchString)).pipe(first()); } diff --git a/packages/frontend/projects/upgrade/src/app/core/http-interceptors/http-error.interceptor.ts b/packages/frontend/projects/upgrade/src/app/core/http-interceptors/http-error.interceptor.ts index eb646c2d1..a7f212029 100755 --- a/packages/frontend/projects/upgrade/src/app/core/http-interceptors/http-error.interceptor.ts +++ b/packages/frontend/projects/upgrade/src/app/core/http-interceptors/http-error.interceptor.ts @@ -34,7 +34,7 @@ export class HttpErrorInterceptor implements HttpInterceptor { if (err.status === 401) { // auto logout if 401 response returned from api this.authService.authLogout(); - } else if (!(err.status === 404 && request.context?.get(HANDLES_404_CONTEXTUALLY))) { + } else if (!(err.status === 404 && request.context.get(HANDLES_404_CONTEXTUALLY))) { this.openPopup(err); } // re-throw to allow the error to be caught by the calling code 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 43d965423..c0020af80 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 @@ -11,13 +11,13 @@ import { Injectable } from '@angular/core'; import { Router } from '@angular/router'; import { Actions, createEffect, ofType } from '@ngrx/effects'; import { select, Store } from '@ngrx/store'; -import { catchError, concatMap, filter, first, map, switchMap, tap, withLatestFrom } from 'rxjs/operators'; +import { catchError, concatMap, filter, map, switchMap, tap, withLatestFrom } from 'rxjs/operators'; 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 { LIST_OPTION_TYPE, NUMBER_OF_SEGMENTS, Segment, UpsertSegmentType } from './segments.model'; -import { selectAllSegments, selectGlobalSegments, selectSearchString } from './segments.selectors'; +import { selectGlobalSegments } from './segments.selectors'; import JSZip from 'jszip'; import { of } from 'rxjs'; import { isCanonicalEntityId, PAGE_ERROR_TYPE } from '@shared-component-lib/common-page-error/common-page-error.model'; @@ -95,7 +95,6 @@ export class SegmentsEffects { this.store$.pipe(select(selectRootBatch)), SegmentsActions.batchActions, (event) => this.store$.dispatch(event), - fromStarting, () => { this.store$.dispatch(SegmentsActions.actionSetIsLoadingSegments({ isLoadingSegments: true })); return this.segmentsDataService.fetchSegmentsPaginated(params, !!action.batchRefresh); @@ -315,8 +314,6 @@ export class SegmentsEffects { ) ); - private getSearchString$ = () => this.store$.pipe(select(selectSearchString)).pipe(first()); - // TODO: this should be replaced with the common download() method in common-export-helpers service in new experience private download(filename, text, isZip: boolean) { const element = document.createElement('a'); diff --git a/packages/frontend/projects/upgrade/src/app/features/dashboard/batch-delete-ui.spec.ts b/packages/frontend/projects/upgrade/src/app/features/dashboard/batch-delete-ui.spec.ts index 23b57dc0e..24c797662 100644 --- a/packages/frontend/projects/upgrade/src/app/features/dashboard/batch-delete-ui.spec.ts +++ b/packages/frontend/projects/upgrade/src/app/features/dashboard/batch-delete-ui.spec.ts @@ -422,7 +422,7 @@ describe.each(cases)('$entity root batch UI', (config) => { fixture.detectChanges(); expect(Object.keys(batch().selectedById)).toHaveLength(1); - store.dispatch(actions.listRequested({ requestId: 'failed-refresh', fromStarting: true })); + store.dispatch(actions.listRequested({ requestId: 'failed-refresh' })); store.dispatch(actions.listFailed({ requestId: 'failed-refresh' })); fixture.detectChanges(); expect(fixture.nativeElement.querySelector('app-common-batch-selection-status')).toBeNull(); diff --git a/packages/frontend/projects/upgrade/src/app/shared-standalone-component-lib/components/common-simple-text-validated-confirmation-modal/batch-confirmation.spec.ts b/packages/frontend/projects/upgrade/src/app/shared-standalone-component-lib/components/common-simple-text-validated-confirmation-modal/batch-confirmation.spec.ts index c49ae9479..d4da03db8 100644 --- a/packages/frontend/projects/upgrade/src/app/shared-standalone-component-lib/components/common-simple-text-validated-confirmation-modal/batch-confirmation.spec.ts +++ b/packages/frontend/projects/upgrade/src/app/shared-standalone-component-lib/components/common-simple-text-validated-confirmation-modal/batch-confirmation.spec.ts @@ -96,19 +96,6 @@ describe('Batch deletion using the existing text confirmation dialog', () => { expect(container.querySelector('mat-progress-bar')).toBeNull(); })); - it.each(['cancel', 'close'])( - 'allows %s without confirming', - fakeAsync((action) => { - open(); - const closed = jest.fn(); - ref.afterClosed().subscribe(closed); - (container.querySelector(`.${action}-btn`) as HTMLElement).click(); - tick(); - expect(closed).toHaveBeenCalledTimes(1); - expect(closed).not.toHaveBeenCalledWith(true); - }) - ); - it('ignores Escape and backdrop clicks', fakeAsync(() => { open(); container diff --git a/packages/frontend/projects/upgrade/src/app/shared/directives/root-batch-actions.directive.ts b/packages/frontend/projects/upgrade/src/app/shared/directives/root-batch-actions.directive.ts index 202f57824..e21f0e669 100644 --- a/packages/frontend/projects/upgrade/src/app/shared/directives/root-batch-actions.directive.ts +++ b/packages/frontend/projects/upgrade/src/app/shared/directives/root-batch-actions.directive.ts @@ -17,9 +17,8 @@ export function rootBatchView(state: RootBatchState, entity: BatchDeleteEntity) : ''; return { ...selection, - state, messageKey, - menuDisabled: !selection.canRequestConfirmation || !!messageKey, + menuDisabled: !selection.canRequestConfirmation, menuItems: [ { label: `batch-delete.dialog.${entity}.title`, diff --git a/packages/frontend/projects/upgrade/src/assets/i18n/en.json b/packages/frontend/projects/upgrade/src/assets/i18n/en.json index 9e504692e..0c70bd340 100644 --- a/packages/frontend/projects/upgrade/src/assets/i18n/en.json +++ b/packages/frontend/projects/upgrade/src/assets/i18n/en.json @@ -20,7 +20,6 @@ "batch-delete.dialog.segments.other": "Are you sure you want to delete {{count}} segments?", "batch-delete.dialog.hidden.one": "1 selected item is not shown in the current list. It will also be deleted.", "batch-delete.dialog.hidden.other": "{{count}} selected items are not shown in the current list. They will also be deleted.", - "batch-delete.reason.not_found": "Already absent.", "batch-delete.reason.missing_permission": "You do not have permission to delete the selected items.", "batch-delete.reason.experiment_state_unsupported": "A selected experiment cannot be deleted in its current state. Deselect it to enable deletion.", "batch-delete.reason.feature_flag_enabled": "A selected feature flag is enabled. Deselect it or disable it before deleting.", @@ -28,12 +27,6 @@ "batch-delete.reason.segment_in_use": "Some selected segments are in use. Deselect them to enable deletion.", "batch-delete.reason.protected_segment_type": "Only ordinary public segments can be deleted here.", "batch-delete.reason.eligibility_unavailable": "Deletion eligibility is unavailable. Reload the page and review the selection.", - "batch-delete.reason.delete_failed": "Could not be deleted.", - "batch-delete.reason.lock_timeout": "Another operation is using this item. Deletion could not complete.", - "batch-delete.reason.external_sync_failed": "Deletion could not complete because an external service could not be updated.", - "batch-delete.reason.outcome_unknown": "Deletion outcome could not be confirmed.", - "batch-delete.reason.post_delete_failed": "Deleted, but related updates could not be completed.", - "batch-delete.reason.batch_budget_exceeded": "Deletion was not attempted because the request took too long.", "batch-delete.success.experiments.one": "1 experiment deleted.", "batch-delete.success.experiments.other": "{{deleted}} experiments deleted.", "batch-delete.success.flags.one": "1 feature flag deleted.", From ac76bfdae7029f6557286b6437c5413eee1ab706 Mon Sep 17 00:00:00 2001 From: Zack Lee Date: Fri, 11 Sep 2026 16:13:30 -0400 Subject: [PATCH 10/61] fix: preserve root table query behavior during batch deletion --- .../batch-actions.integration.spec.ts | 74 ++++++++++++++++++- .../experiments/store/experiments.effects.ts | 2 - .../store/feature-flags.effects.ts | 2 - .../core/segments/store/segments.effects.ts | 2 - ...eature-flag-root-section-card.component.ts | 2 +- 5 files changed, 74 insertions(+), 8 deletions(-) diff --git a/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.integration.spec.ts b/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.integration.spec.ts index 89b2f326d..ec763f517 100644 --- a/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.integration.spec.ts +++ b/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.integration.spec.ts @@ -7,8 +7,10 @@ import { DeletionReasonCode, EXPERIMENT_STATE, FEATURE_FLAG_STATUS, + FLAG_SEARCH_KEY, SEGMENT_STATUS, SEGMENT_TYPE, + SORT_AS_DIRECTION, UserRole, } from 'upgrade_types'; import * as experimentActions from '../experiments/store/experiments.actions'; @@ -19,6 +21,8 @@ import { featureFlagsReducer } from '../feature-flags/store/feature-flags.reduce import { segmentsReducer } from '../segments/store/segments.reducer'; import { ExperimentEffects } from '../experiments/store/experiments.effects'; import { FeatureFlagsEffects } from '../feature-flags/store/feature-flags.effects'; +import { FeatureFlagsService } from '../feature-flags/feature-flags.service'; +import { FeatureFlagRootSectionCardComponent } from '../../features/dashboard/feature-flags/pages/feature-flag-root-page/feature-flag-root-page-content/feature-flag-root-section-card/feature-flag-root-section-card.component'; import { SegmentsEffects } from '../segments/store/segments.effects'; import { actionLogoutStart, actionSetUserInfo } from '../auth/store/auth.actions'; import { batchResultCounts, selectionItem, selectionView } from './batch-actions.helpers'; @@ -176,6 +180,32 @@ describe.each(fixtures)('$entity batch store/effects integration', (config) => { TestBed.resetTestingModule(); }); + if (config.entity === 'flags') { + it('clears the search through the root handler without cancelling the unfiltered request', () => { + subscriptions.add(effects.fetchFeatureFlagsOnSearchString$.subscribe()); + subscriptions.add(effects.fetchFlagsOnSearchKeyChange$.subscribe()); + const featureFlagService = new FeatureFlagsService(store as any, { setItem: jest.fn() } as any); + const search = (searchString: string) => + FeatureFlagRootSectionCardComponent.prototype.onSearch.call({ featureFlagService } as any, { + searchKey: FLAG_SEARCH_KEY.NAME, + searchString, + }); + data[config.fetchMethod].mockReturnValue(of(page([rows[0]]))); + search('a'); + const unfiltered = new Subject(); + data[config.fetchMethod].mockReturnValue(unfiltered); + search(''); + expect(state.featureFlags.searchValue).toBe(''); + expect(data[config.fetchMethod].mock.calls.at(-1)[0].searchParams).toBeUndefined(); + unfiltered.next(page()); + unfiltered.complete(); + expect(currentRows().map(({ id }) => id)).toEqual(rows.map(({ id }) => id)); + expect(batch().loadedIds).toHaveLength(3); + expect(batch().listLoading).toBe(false); + expect(state.featureFlags.isLoadingFeatureFlags).toBe(false); + }); + } + it('selects loaded rows, retains hidden selections across replacement reads, and clears all from the header', () => { expect(batch().loadedIds).toHaveLength(3); // Also exercises NgRx's queued list-start dispatch. selectRows(2); @@ -311,6 +341,44 @@ describe.each(fixtures)('$entity batch store/effects integration', (config) => { expect(router.navigate).not.toHaveBeenCalled(); }); + it.each(['search', 'sort'])( + 'applies a user-requested %s while deletion is pending even if deletion fails', + (change) => { + selectRows(); + const snapshot = prepare(); + store.dispatch(actions.batchDeleteRequested({ snapshot })); + const pendingList = new Subject(); + data[config.fetchMethod].mockReturnValueOnce(pendingList); + if (change === 'search') { + store.dispatch(config.actions.actionSetSearchString({ searchString: 'b' })); + } else { + store.dispatch(config.actions.actionSetSortingType({ sortingType: SORT_AS_DIRECTION.DESCENDING })); + } + store.dispatch(config.fetch({ fromStarting: true })); + expect(data[config.fetchMethod]).toHaveBeenCalledTimes(2); + expect(data[config.fetchMethod]).toHaveBeenLastCalledWith( + expect.objectContaining( + change === 'search' + ? { searchParams: expect.objectContaining({ string: 'b' }) } + : { sortParams: expect.objectContaining({ sortAs: SORT_AS_DIRECTION.DESCENDING }) } + ), + false + ); + response.error({ status: 0 }); + const resultRows = change === 'search' ? [rows[1]] : [...rows].reverse(); + pendingList.next(page(resultRows)); + pendingList.complete(); + expect(currentRows().map(({ id }) => id)).toEqual(resultRows.map(({ id }) => id)); + expect(batch().loadedIds).toEqual(resultRows.map(({ id }) => id)); + expect(batch().listLoading).toBe(false); + expect(Object.keys(batch().selectedById)).toHaveLength(3); + expect(data[config.fetchMethod]).toHaveBeenCalledTimes(2); + expect(data.checkDeletionEligibility).not.toHaveBeenCalled(); + expect(notifications.showWarning).not.toHaveBeenCalled(); + expect(notifications.showSuccess).not.toHaveBeenCalled(); + } + ); + it('removes two successful deletions and retains only the selection whose state changed', () => { selectRows(); const snapshot = prepare(); @@ -349,7 +417,7 @@ describe.each(fixtures)('$entity batch store/effects integration', (config) => { expect(data.batchDelete).toHaveBeenCalledWith([rows[0].id, rows[1].id]); })); - it.each([1, 3])('reports %i deletions once and ignores a list response from before deletion', (count) => { + it.each([1, 3])('reports %i deletions once and ignores stale reads started before or during deletion', (count) => { selectRows(count); const snapshot = prepare(); const pending = new Subject(); @@ -357,8 +425,12 @@ describe.each(fixtures)('$entity batch store/effects integration', (config) => { store.dispatch(config.fetch({ fromStarting: true })); const oldRequestId = batch().listRequestId; store.dispatch(actions.batchDeleteRequested({ snapshot })); + const duringDeletion = new Subject(); + data[config.fetchMethod].mockReturnValueOnce(duringDeletion); + store.dispatch(config.fetch({ fromStarting: true })); response.next({ phase: 'executed', results: rows.slice(0, count).map(({ id }) => ({ id, outcome: 'deleted' })) }); pending.next(page()); + duringDeletion.next(page()); const oldSuccess = events.find( (action: any) => action.batchListRequestId && action.type.includes('Success') ) as any; 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 451a47a71..7fe5bda56 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 @@ -4,7 +4,6 @@ import { batchFinishedEffect, trackedListRequest, } from '../../batch-actions/batch-actions.effects'; -import { isBatchBusy } from '../../batch-actions/batch-actions.models'; import { batchResultCounts, batchResultMessage } from '../../batch-actions/batch-actions.helpers'; import { selectRootBatch, selectExperimentState } from './experiments.selectors'; import { actionFetchListSegmentOptions } from '../../segments/store/segments.actions'; @@ -103,7 +102,6 @@ export class ExperimentEffects { withLatestFrom(this.store$.pipe(select(selectExperimentState))), filter( ([action, state]) => - !isBatchBusy(state.rootBatch) && (!state.rootBatch.listLoading || action.fromStarting) && (action.fromStarting || state.totalExperiments === null || state.skipExperiment < state.totalExperiments) ), 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 1f173c3d2..bcd562dbe 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 @@ -4,7 +4,6 @@ import { batchFinishedEffect, trackedListRequest, } from '../../batch-actions/batch-actions.effects'; -import { isBatchBusy } from '../../batch-actions/batch-actions.models'; import { batchResultCounts, batchResultMessage } from '../../batch-actions/batch-actions.helpers'; import { selectRootBatch, selectFeatureFlagsState } from './feature-flags.selectors'; import { actionFetchListSegmentOptions } from '../../segments/store/segments.actions'; @@ -82,7 +81,6 @@ export class FeatureFlagsEffects { withLatestFrom(this.store$.pipe(select(selectFeatureFlagsState))), filter( ([action, state]) => - !isBatchBusy(state.rootBatch) && (!state.rootBatch.listLoading || action.fromStarting) && (action.fromStarting || state.totalFlags === null || state.skipFlags < state.totalFlags) ), 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 c0020af80..aa5705de6 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 @@ -4,7 +4,6 @@ import { batchFinishedEffect, trackedListRequest, } from '../../batch-actions/batch-actions.effects'; -import { isBatchBusy } from '../../batch-actions/batch-actions.models'; import { batchResultCounts, batchResultMessage } from '../../batch-actions/batch-actions.helpers'; import { selectRootBatch, selectSegmentsState } from './segments.selectors'; import { Injectable } from '@angular/core'; @@ -79,7 +78,6 @@ export class SegmentsEffects { withLatestFrom(this.store$.pipe(select(selectSegmentsState))), filter( ([action, state]) => - !isBatchBusy(state.rootBatch) && (!state.rootBatch.listLoading || action.fromStarting) && (action.fromStarting || state.totalSegments === null || state.skipSegments < state.totalSegments) ), diff --git a/packages/frontend/projects/upgrade/src/app/features/dashboard/feature-flags/pages/feature-flag-root-page/feature-flag-root-page-content/feature-flag-root-section-card/feature-flag-root-section-card.component.ts b/packages/frontend/projects/upgrade/src/app/features/dashboard/feature-flags/pages/feature-flag-root-page/feature-flag-root-page-content/feature-flag-root-section-card/feature-flag-root-section-card.component.ts index 788292977..a06b7d351 100644 --- a/packages/frontend/projects/upgrade/src/app/features/dashboard/feature-flags/pages/feature-flag-root-page/feature-flag-root-page-content/feature-flag-root-section-card/feature-flag-root-section-card.component.ts +++ b/packages/frontend/projects/upgrade/src/app/features/dashboard/feature-flags/pages/feature-flag-root-page/feature-flag-root-page-content/feature-flag-root-section-card/feature-flag-root-section-card.component.ts @@ -92,8 +92,8 @@ export class FeatureFlagRootSectionCardComponent { } onSearch(params: CommonSearchWidgetSearchParams) { - this.featureFlagService.setSearchString(params.searchString?.trim() || ''); this.featureFlagService.setSearchKey(params.searchKey as FLAG_SEARCH_KEY); + this.featureFlagService.setSearchString(params.searchString?.trim() || ''); } onAddFeatureFlagButtonClick() { From 9b74e33d66d7c24fa96f613d854ae30c9cb771d8 Mon Sep 17 00:00:00 2001 From: Zack Lee Date: Fri, 11 Sep 2026 18:14:32 -0400 Subject: [PATCH 11/61] fix: clear root table loading state when cancelling list requests --- .../batch-actions.integration.spec.ts | 15 ++++++++++++++- .../app/core/batch-actions/batch-actions.store.ts | 4 ++++ .../core/experiments/store/experiments.reducer.ts | 1 + .../feature-flags/store/feature-flags.reducer.ts | 1 + .../app/core/segments/store/segments.reducer.ts | 1 + 5 files changed, 21 insertions(+), 1 deletion(-) diff --git a/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.integration.spec.ts b/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.integration.spec.ts index ec763f517..3f2b8d017 100644 --- a/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.integration.spec.ts +++ b/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.integration.spec.ts @@ -32,6 +32,7 @@ const fixtures = [ { entity: 'experiments', key: 'experiments', + loadingKey: 'isLoadingExperiment', actions: experimentActions, fetchEffect: 'getPaginatedExperiment$', fetchMethod: 'getAllExperiment', @@ -40,6 +41,7 @@ const fixtures = [ { entity: 'flags', key: 'featureFlags', + loadingKey: 'isLoadingFeatureFlags', actions: flagActions, fetchEffect: 'fetchFeatureFlags$', fetchMethod: 'fetchFeatureFlagsPaginated', @@ -48,6 +50,7 @@ const fixtures = [ { entity: 'segments', key: 'segments', + loadingKey: 'isLoadingSegments', actions: segmentActions, fetchEffect: 'fetchSegmentsPaginated$', fetchMethod: 'fetchSegmentsPaginated', @@ -365,12 +368,14 @@ describe.each(fixtures)('$entity batch store/effects integration', (config) => { false ); response.error({ status: 0 }); + expect(state[config.key][config.loadingKey]).toBe(true); const resultRows = change === 'search' ? [rows[1]] : [...rows].reverse(); pendingList.next(page(resultRows)); pendingList.complete(); expect(currentRows().map(({ id }) => id)).toEqual(resultRows.map(({ id }) => id)); expect(batch().loadedIds).toEqual(resultRows.map(({ id }) => id)); expect(batch().listLoading).toBe(false); + expect(state[config.key][config.loadingKey]).toBe(false); expect(Object.keys(batch().selectedById)).toHaveLength(3); expect(data[config.fetchMethod]).toHaveBeenCalledTimes(2); expect(data.checkDeletionEligibility).not.toHaveBeenCalled(); @@ -468,16 +473,24 @@ describe.each(fixtures)('$entity batch store/effects integration', (config) => { }); it.each([0, 400, 403, 500, 504])( - 'releases selection after HTTP %i without another request or result snackbar', + 'clears cancelled list loading and releases selection after HTTP %i without another request or snackbar', (status) => { selectRows(); const snapshot = prepare(); + const pendingList = new Subject(); + data[config.fetchMethod].mockReturnValueOnce(pendingList); + store.dispatch(config.fetch({ fromStarting: true })); + expect(state[config.key][config.loadingKey]).toBe(true); const fetchCount = data[config.fetchMethod].mock.calls.length; const beforeRows = currentRows(); const loadedIds = [...batch().loadedIds]; store.dispatch(actions.batchDeleteRequested({ snapshot })); response.error({ status }); + pendingList.next(page([])); + pendingList.complete(); expect(batch().operation.status).toBe('complete'); + expect(batch().listLoading).toBe(false); + expect(state[config.key][config.loadingKey]).toBe(false); expect(currentRows()).toEqual(beforeRows); expect(batch().loadedIds).toEqual(loadedIds); expect(data.checkDeletionEligibility).not.toHaveBeenCalled(); diff --git a/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.store.ts b/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.store.ts index 4dba8a373..d8a7359a7 100644 --- a/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.store.ts +++ b/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.store.ts @@ -9,6 +9,7 @@ interface RootListConfig { entity: BatchDeleteEntity; actions: RootBatchActions; rowsKey: string; + loadingKey: string; skipKey: string; totalKey: string; queryTypes: string[]; @@ -53,6 +54,9 @@ export function withRootBatch( // Tombstones also protect against late detail/stat responses, whose IDs are not tied to a root query. const removed = new Set(rootBatch.removedIds); let result = next; + // A cancelled tracked read cannot dispatch its usual success/failure action to clear loading. + if (state.rootBatch.listLoading && state.rootBatch.listRequestId && !rootBatch.listRequestId) + result = { ...result, [config.loadingKey]: false }; if (action.type === config.listSuccessType) { const rows = result[config.rowsKey]; const distinct = new Map(rows.map((row) => [row.id, row])); diff --git a/packages/frontend/projects/upgrade/src/app/core/experiments/store/experiments.reducer.ts b/packages/frontend/projects/upgrade/src/app/core/experiments/store/experiments.reducer.ts index 127c5af46..bf2147a05 100644 --- a/packages/frontend/projects/upgrade/src/app/core/experiments/store/experiments.reducer.ts +++ b/packages/frontend/projects/upgrade/src/app/core/experiments/store/experiments.reducer.ts @@ -535,6 +535,7 @@ const batchReducer = withRootBatch(reducer, initialState, { entity: 'experiments', actions: experimentsAction.batchActions, rowsKey: 'experiments', + loadingKey: 'isLoadingExperiment', skipKey: 'skipExperiment', totalKey: 'totalExperiments', queryTypes: [ diff --git a/packages/frontend/projects/upgrade/src/app/core/feature-flags/store/feature-flags.reducer.ts b/packages/frontend/projects/upgrade/src/app/core/feature-flags/store/feature-flags.reducer.ts index 15269adfd..1d019378c 100644 --- a/packages/frontend/projects/upgrade/src/app/core/feature-flags/store/feature-flags.reducer.ts +++ b/packages/frontend/projects/upgrade/src/app/core/feature-flags/store/feature-flags.reducer.ts @@ -401,6 +401,7 @@ const batchReducer = withRootBatch(reducer, initialState, { entity: 'flags', actions: FeatureFlagsActions.batchActions, rowsKey: 'featureFlags', + loadingKey: 'isLoadingFeatureFlags', skipKey: 'skipFlags', totalKey: 'totalFlags', queryTypes: [ diff --git a/packages/frontend/projects/upgrade/src/app/core/segments/store/segments.reducer.ts b/packages/frontend/projects/upgrade/src/app/core/segments/store/segments.reducer.ts index 91b03b5b3..ff9542065 100644 --- a/packages/frontend/projects/upgrade/src/app/core/segments/store/segments.reducer.ts +++ b/packages/frontend/projects/upgrade/src/app/core/segments/store/segments.reducer.ts @@ -194,6 +194,7 @@ const batchReducer = withRootBatch(reducer, initialState, { entity: 'segments', actions: SegmentsActions.batchActions, rowsKey: 'segments', + loadingKey: 'isLoadingSegments', skipKey: 'skipSegments', totalKey: 'totalSegments', queryTypes: [ From c9291313ecc7a3c71e00026d137c80c01e48c2a2 Mon Sep 17 00:00:00 2001 From: Zack Lee Date: Sat, 12 Sep 2026 20:05:05 -0400 Subject: [PATCH 12/61] fix: allow selecting loaded rows while additional pages load --- .../core/batch-actions/batch-actions.helpers.ts | 2 +- .../batch-actions.integration.spec.ts | 16 +++++++++++++--- .../core/batch-actions/batch-actions.reducer.ts | 2 +- 3 files changed, 15 insertions(+), 5 deletions(-) diff --git a/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.helpers.ts b/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.helpers.ts index 69f3682e8..a50cb7090 100644 --- a/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.helpers.ts +++ b/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.helpers.ts @@ -50,7 +50,7 @@ export function selectionView(state: RootBatchState, entity: BatchDeleteEntity) checked, indeterminate: items.length > 0 && !checked, notShownCount: items.filter((item) => !loaded.has(item.id)).length, - canToggleHeader: !isBatchBusy(state) && (items.length > 0 || (!state.listLoading && loaded.size > 0)), + canToggleHeader: !isBatchBusy(state) && (items.length > 0 || loaded.size > 0), canRequestConfirmation: items.length > 0 && !reasons.length && !isBatchBusy(state), reasonCode: reasons[0], busy: isBatchBusy(state), diff --git a/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.integration.spec.ts b/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.integration.spec.ts index 3f2b8d017..730067e3b 100644 --- a/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.integration.spec.ts +++ b/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.integration.spec.ts @@ -222,15 +222,25 @@ describe.each(fixtures)('$entity batch store/effects integration', (config) => { }); store.dispatch(actions.toggleHeader({ items: [selectionItem(rows[0])] })); expect(Object.keys(batch().selectedById)).toEqual([]); + store.dispatch(config.actions.actionSetSearchString({ searchString: 'replacement query' })); + expect(selectionView(batch(), config.entity).canToggleHeader).toBe(false); + store.dispatch(actions.toggleHeader({ items: rows.map(selectionItem) })); + expect(Object.keys(batch().selectedById)).toEqual([]); }); - it('keeps newly appended rows unselected and deduplicates overlapping responses', () => { + it('selects loaded rows during incremental loading and leaves newly appended rows unselected', () => { data[config.fetchMethod].mockReturnValueOnce(of({ ...page(rows.slice(0, 2)), total: 3 })); store.dispatch(config.fetch({ fromStarting: true })); - store.dispatch(actions.toggleHeader({ items: rows.map(selectionItem) })); - data[config.fetchMethod].mockReturnValueOnce(of({ ...page(rows.slice(1)), total: 3 })); + const nextPage = new Subject(); + data[config.fetchMethod].mockReturnValueOnce(nextPage); store.dispatch(config.fetch({ fromStarting: false })); expect(data[config.fetchMethod]).toHaveBeenLastCalledWith(expect.objectContaining({ skip: 2 }), false); + expect(batch().listLoading).toBe(true); + expect(selectionView(batch(), config.entity).canToggleHeader).toBe(true); + store.dispatch(actions.toggleHeader({ items: currentRows().map(selectionItem) })); + expect(Object.keys(batch().selectedById)).toEqual(rows.slice(0, 2).map(({ id }) => id)); + nextPage.next({ ...page(rows.slice(1)), total: 3 }); + nextPage.complete(); expect(batch().loadedIds).toHaveLength(3); expect(currentRows()).toHaveLength(3); expect(selectionView(batch(), config.entity)).toMatchObject({ diff --git a/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.reducer.ts b/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.reducer.ts index a26200689..fcc1d1aef 100644 --- a/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.reducer.ts +++ b/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.reducer.ts @@ -91,7 +91,7 @@ export function reduceRootBatch( } else { const items = matches(action, actions.toggleRow) ? [action.item] - : matches(action, actions.toggleHeader) && !state.listLoading + : matches(action, actions.toggleHeader) ? action.items : []; for (const item of items) { From e0c4f9c03f69a28fae5af21c8fa9ad5e2d503994 Mon Sep 17 00:00:00 2001 From: Zack Lee Date: Sat, 12 Sep 2026 23:17:04 -0400 Subject: [PATCH 13/61] fix: avoid duplicate notifications after reconciliation HTTP failures --- .../batch-actions/batch-actions.actions.ts | 2 +- .../batch-actions/batch-actions.effects.ts | 4 ++- .../batch-actions.integration.spec.ts | 36 ++++++++++++++++++- .../batch-actions/batch-actions.reducer.ts | 2 ++ 4 files changed, 41 insertions(+), 3 deletions(-) diff --git a/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.actions.ts b/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.actions.ts index 0677e7c93..7bfbac29b 100644 --- a/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.actions.ts +++ b/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.actions.ts @@ -22,7 +22,7 @@ export function createBatchActions(source: string) { ), reconciliationCompleted: createAction( `${prefix} Reconciliation Completed`, - props<{ operationId: string; result: DeletionEligibilityResult | null }>() + props<{ operationId: string; result: DeletionEligibilityResult | null; status?: number }>() ), listRequested: createAction(`${prefix} List Requested`, props<{ requestId: string }>()), listFailed: createAction(`${prefix} List Failed`, props<{ requestId: string }>()), diff --git a/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.effects.ts b/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.effects.ts index 4b97d7c9b..967c3e072 100644 --- a/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.effects.ts +++ b/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.effects.ts @@ -95,7 +95,9 @@ export function reconcileBatchEffect( map((result) => actions.reconciliationCompleted({ operationId, result: validateEligibilityResponse(result, ids) }) ), - catchError(() => of(actions.reconciliationCompleted({ operationId, result: null }))), + catchError((error) => + of(actions.reconciliationCompleted({ operationId, result: null, status: error?.status })) + ), takeUntil(state$.pipe(filter((current) => current.userEmail !== state.userEmail || !current.operation))) ); }) diff --git a/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.integration.spec.ts b/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.integration.spec.ts index 730067e3b..1bd193656 100644 --- a/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.integration.spec.ts +++ b/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.integration.spec.ts @@ -1,4 +1,5 @@ import { TestBed, fakeAsync, tick } from '@angular/core/testing'; +import { HttpErrorResponse, HttpRequest } from '@angular/common/http'; import { TranslateModule, TranslateService } from '@ngx-translate/core'; import { Actions, getEffectsMetadata } from '@ngrx/effects'; import { Action, ScannedActionsSubject, Store, StoreModule } from '@ngrx/store'; @@ -25,6 +26,7 @@ import { FeatureFlagsService } from '../feature-flags/feature-flags.service'; import { FeatureFlagRootSectionCardComponent } from '../../features/dashboard/feature-flags/pages/feature-flag-root-page/feature-flag-root-page-content/feature-flag-root-section-card/feature-flag-root-section-card.component'; import { SegmentsEffects } from '../segments/store/segments.effects'; import { actionLogoutStart, actionSetUserInfo } from '../auth/store/auth.actions'; +import { HttpErrorInterceptor } from '../http-interceptors/http-error.interceptor'; import { batchResultCounts, selectionItem, selectionView } from './batch-actions.helpers'; import { RootBatchState, newBatchRequestId } from './batch-actions.models'; @@ -482,6 +484,35 @@ describe.each(fixtures)('$entity batch store/effects integration', (config) => { expect(notifications.showWarning).toHaveBeenCalledTimes(1); }); + it.each([0, 500])('uses only the existing error notification when reconciliation fails with HTTP %i', (status) => { + selectRows(); + const snapshot = prepare(); + const popup = { create: jest.fn() }; + const interceptor = new HttpErrorInterceptor({ authLogout: jest.fn() } as any, popup as any, {} as any); + data.checkDeletionEligibility.mockReturnValue( + interceptor.intercept(new HttpRequest('POST', `/${config.entity}/deletion-eligibility`, {}), { + handle: () => throwError(() => new HttpErrorResponse({ status })), + }) + ); + const fetchCount = data[config.fetchMethod].mock.calls.length; + store.dispatch(actions.batchDeleteRequested({ snapshot })); + response.next({ + phase: 'executed', + results: rows.map(({ id }, index) => ({ id, outcome: index === 0 ? 'deleted' : 'unknown' })), + }); + expect(popup.create).toHaveBeenCalledTimes(1); + expect(popup.create.mock.calls[0][0]).toBe('Network call failed. See console for details.'); + expect(notifications.showWarning).not.toHaveBeenCalled(); + expect(notifications.showSuccess).not.toHaveBeenCalled(); + expect(data[config.fetchMethod]).toHaveBeenCalledTimes(fetchCount); + expect(data.checkDeletionEligibility).toHaveBeenCalledTimes(1); + expect(data.batchDelete).toHaveBeenCalledTimes(1); + expect(batch().operation.status).toBe('complete'); + expect(currentRows().map(({ id }) => id)).toEqual(rows.slice(1).map(({ id }) => id)); + expect(Object.keys(batch().selectedById)).toEqual(rows.slice(1).map(({ id }) => id)); + expect(selectionView(batch(), config.entity).canToggleHeader).toBe(true); + }); + it.each([0, 400, 403, 500, 504])( 'clears cancelled list loading and releases selection after HTTP %i without another request or snackbar', (status) => { @@ -556,14 +587,17 @@ describe.each(fixtures)('$entity batch store/effects integration', (config) => { expect(notifications.showWarning).toHaveBeenCalledTimes(1); }); - it('retains unknown items after a malformed response without retrying deletion', () => { + it('warns about malformed delete and reconciliation responses without retrying deletion', () => { selectRows(); const snapshot = prepare(); + data.checkDeletionEligibility.mockReturnValueOnce(of({ items: [], allDeletable: false })); store.dispatch(actions.batchDeleteRequested({ snapshot })); response.next({ phase: 'executed', results: [] }); expect(batch().operation.result.results.every((result) => result.outcome === 'unknown')).toBe(true); expect(Object.keys(batch().selectedById)).toHaveLength(3); expect(data.batchDelete).toHaveBeenCalledTimes(1); + expect(data.checkDeletionEligibility).toHaveBeenCalledTimes(1); + expect(notifications.showWarning).toHaveBeenCalledTimes(1); }); it('requires a new confirmation and sends only retained IDs on an explicit retry', () => { diff --git a/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.reducer.ts b/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.reducer.ts index fcc1d1aef..ab3f20ce7 100644 --- a/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.reducer.ts +++ b/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.reducer.ts @@ -196,6 +196,8 @@ export function reduceRootBatch( status: 'complete', reconciledAbsentIds: absent.filter((id) => !confirmedRemovedIds(state.operation.result).includes(id)), reconciliationFailed: !action.result, + // Reuse the HTTP-failure completion path: the interceptor already reported this error. + transportStatus: action.status, }, }; } From aa38ca1b820379315b976a80b3fa763897c22f0e Mon Sep 17 00:00:00 2001 From: Zack Lee Date: Mon, 14 Sep 2026 10:53:20 -0400 Subject: [PATCH 14/61] refactor: remove deletion eligibility endpoints and frontend reconciliation --- .../api/controllers/ExperimentController.ts | 36 +- .../api/controllers/FeatureFlagController.ts | 36 +- .../src/api/controllers/SegmentController.ts | 68 +--- .../api/services/batch/BatchDeleteService.ts | 17 +- .../batch/DeletionEligibilityService.ts | 76 ++-- .../test/integration/BatchDelete/index.ts | 4 +- .../integration/DeletionEligibility/index.ts | 345 ------------------ .../backend/test/integration/index.test.ts | 2 - .../controllers/ExperimentController.test.ts | 2 - .../controllers/FeatureFlagController.test.ts | 2 - .../controllers/SegmentController.test.ts | 2 - .../services/batch/BatchDeleteService.test.ts | 10 +- .../batch/DeletionEligibilityService.test.ts | 70 ++-- .../src/app/core/api-endpoints.constants.ts | 3 - .../batch-actions/batch-actions.actions.ts | 6 +- .../batch-actions/batch-actions.effects.ts | 42 +-- .../batch-actions.helpers.spec.ts | 10 +- .../batch-actions/batch-actions.helpers.ts | 33 +- .../batch-actions/batch-actions.http.spec.ts | 8 +- .../batch-actions.integration.spec.ts | 135 ++----- .../batch-actions/batch-actions.models.ts | 23 +- .../batch-actions/batch-actions.reducer.ts | 23 +- .../experiments/experiments.data.service.ts | 10 +- .../experiments/store/experiments.effects.ts | 15 +- .../feature-flags.data.service.ts | 10 +- .../store/feature-flags.effects.ts | 15 +- .../core/segments/segments.data.service.ts | 10 +- .../core/segments/store/segments.effects.ts | 15 +- .../src/environments/environment-types.ts | 3 - packages/types/src/BatchActions/index.ts | 19 +- packages/types/src/index.ts | 3 - 31 files changed, 162 insertions(+), 891 deletions(-) delete mode 100644 packages/backend/test/integration/DeletionEligibility/index.ts diff --git a/packages/backend/src/api/controllers/ExperimentController.ts b/packages/backend/src/api/controllers/ExperimentController.ts index 3f253aeed..aeda4a193 100644 --- a/packages/backend/src/api/controllers/ExperimentController.ts +++ b/packages/backend/src/api/controllers/ExperimentController.ts @@ -1,8 +1,7 @@ -import { DeletionEligibilityResult, BatchDeleteResult } from 'upgrade_types'; +import { BatchDeleteResult } from 'upgrade_types'; import { Inject } from 'typedi'; import { BatchDeleteService } from '../services/batch/BatchDeleteService'; import { BatchEntityIdsValidator } from './validators/BatchEntityIdsValidator'; -import { DeletionEligibilityService } from '../services/batch/DeletionEligibilityService'; import { DeletionStateService } from '../services/DeletionStateService'; import { Body, @@ -671,43 +670,10 @@ export class ExperimentController { public moocletRewardService: MoocletRewardsService, public importExportService: ImportExportService, public cacheService: CacheService, - private deletionEligibilityService: DeletionEligibilityService, @Inject(() => BatchDeleteService) private batchDeleteService: BatchDeleteService, private deletionStateService: DeletionStateService ) {} - /** - * @swagger - * /experiments/deletion-eligibility: - * post: - * summary: Check deletion eligibility for the selected experiments - * description: Read-only; returns one result per ID, including hidden selections. Does not reserve or delete items. - * tags: - * - Experiments - * parameters: - * - in: body - * name: selection - * required: true - * schema: - * $ref: '#/definitions/BatchEntityIdsRequest' - * responses: - * '200': - * description: Eligibility and reasons, in request order; users without delete permission receive canDelete=false. - * schema: - * $ref: '#/definitions/DeletionEligibilityResult' - * '400': - * description: Expected a nonempty array of unique UUIDs. - * '401': - * description: A current authenticated user is required. - */ - @Post('/deletion-eligibility') - public getDeletionEligibility( - @Body({ validate: true }) { ids }: BatchEntityIdsValidator, - @CurrentUser({ required: true }) currentUser: UserDTO - ): Promise { - return this.deletionEligibilityService.experiments(ids, currentUser); - } - /** * @swagger * /experiments/batch-delete: diff --git a/packages/backend/src/api/controllers/FeatureFlagController.ts b/packages/backend/src/api/controllers/FeatureFlagController.ts index f69c4f978..97a455b4a 100644 --- a/packages/backend/src/api/controllers/FeatureFlagController.ts +++ b/packages/backend/src/api/controllers/FeatureFlagController.ts @@ -1,8 +1,7 @@ -import { DeletionEligibilityResult, BatchDeleteResult } from 'upgrade_types'; +import { BatchDeleteResult } from 'upgrade_types'; import { Inject } from 'typedi'; import { BatchDeleteService } from '../services/batch/BatchDeleteService'; import { BatchEntityIdsValidator } from './validators/BatchEntityIdsValidator'; -import { DeletionEligibilityService } from '../services/batch/DeletionEligibilityService'; import { DeletionStateService } from '../services/DeletionStateService'; import { JsonController, @@ -163,43 +162,10 @@ export class FeatureFlagsController { constructor( public featureFlagService: FeatureFlagService, public experimentUserService: ExperimentUserService, - private deletionEligibilityService: DeletionEligibilityService, @Inject(() => BatchDeleteService) private batchDeleteService: BatchDeleteService, private deletionStateService: DeletionStateService ) {} - /** - * @swagger - * /flags/deletion-eligibility: - * post: - * summary: Check deletion eligibility for the selected flags - * description: Read-only; returns one result per ID, including hidden selections. Does not reserve or delete items. - * tags: - * - Feature Flags - * parameters: - * - in: body - * name: selection - * required: true - * schema: - * $ref: '#/definitions/BatchEntityIdsRequest' - * responses: - * '200': - * description: Eligibility and reasons, in request order; users without delete permission receive canDelete=false. - * schema: - * $ref: '#/definitions/DeletionEligibilityResult' - * '400': - * description: Expected a nonempty array of unique UUIDs. - * '401': - * description: A current authenticated user is required. - */ - @Post('/deletion-eligibility') - public getDeletionEligibility( - @Body({ validate: true }) { ids }: BatchEntityIdsValidator, - @CurrentUser({ required: true }) currentUser: UserDTO - ): Promise { - return this.deletionEligibilityService.flags(ids, currentUser); - } - /** * @swagger * /flags/batch-delete: diff --git a/packages/backend/src/api/controllers/SegmentController.ts b/packages/backend/src/api/controllers/SegmentController.ts index b2798cc55..a02e7b5d2 100644 --- a/packages/backend/src/api/controllers/SegmentController.ts +++ b/packages/backend/src/api/controllers/SegmentController.ts @@ -1,9 +1,8 @@ import { UserDTO } from '../DTO/UserDTO'; -import { DeletionEligibilityResult, BatchDeleteResult } from 'upgrade_types'; +import { BatchDeleteResult } from 'upgrade_types'; import { Inject } from 'typedi'; import { BatchDeleteService } from '../services/batch/BatchDeleteService'; import { BatchEntityIdsValidator } from './validators/BatchEntityIdsValidator'; -import { DeletionEligibilityService } from '../services/batch/DeletionEligibilityService'; import { DeletionStateService } from '../services/DeletionStateService'; import { JsonController, @@ -92,38 +91,6 @@ interface SegmentPaginationInfo extends PaginationResponse { * items: * type: string * format: uuid - * DeletionEligibilityItem: - * type: object - * required: [id, availability, canDelete] - * properties: - * id: - * type: string - * format: uuid - * availability: - * type: string - * enum: [present, not_found, unavailable] - * name: - * type: string - * stateOrStatus: - * type: string - * segmentType: - * type: string - * enum: [public, private, global_exclude] - * canDelete: - * type: boolean - * reasonCode: - * type: string - * enum: [not_found, missing_permission, experiment_state_unsupported, feature_flag_enabled, feature_flag_status_unsupported, segment_in_use, protected_segment_type, eligibility_unavailable] - * DeletionEligibilityResult: - * type: object - * required: [items, allDeletable] - * properties: - * items: - * type: array - * items: - * $ref: '#/definitions/DeletionEligibilityItem' - * allDeletable: - * type: boolean * Segment: * required: * - name @@ -310,43 +277,10 @@ interface SegmentPaginationInfo extends PaginationResponse { export class SegmentController { constructor( public segmentService: SegmentService, - private deletionEligibilityService: DeletionEligibilityService, @Inject(() => BatchDeleteService) private batchDeleteService: BatchDeleteService, private deletionStateService: DeletionStateService ) {} - /** - * @swagger - * /segments/deletion-eligibility: - * post: - * summary: Check deletion eligibility for the selected segments - * description: Read-only; returns one result per ID, including hidden selections. Does not reserve or delete items. - * tags: - * - Segment - * parameters: - * - in: body - * name: selection - * required: true - * schema: - * $ref: '#/definitions/BatchEntityIdsRequest' - * responses: - * '200': - * description: Eligibility and reasons, in request order; users without delete permission receive canDelete=false. - * schema: - * $ref: '#/definitions/DeletionEligibilityResult' - * '400': - * description: Expected a nonempty array of unique UUIDs. - * '401': - * description: A current authenticated user is required. - */ - @Post('/deletion-eligibility') - public getDeletionEligibility( - @Body({ validate: true }) { ids }: BatchEntityIdsValidator, - @CurrentUser({ required: true }) currentUser: UserDTO - ): Promise { - return this.deletionEligibilityService.segments(ids, currentUser); - } - /** * @swagger * /segments/batch-delete: diff --git a/packages/backend/src/api/services/batch/BatchDeleteService.ts b/packages/backend/src/api/services/batch/BatchDeleteService.ts index 7e892b1e2..efbfde60e 100644 --- a/packages/backend/src/api/services/batch/BatchDeleteService.ts +++ b/packages/backend/src/api/services/batch/BatchDeleteService.ts @@ -6,8 +6,6 @@ import { BatchDeleteEntity, BatchDeleteItemResult, BatchDeleteResult, - DeletionEligibilityItem, - DeletionEligibilityResult, DeletionReasonCode, hasBatchDeletePermission, } from 'upgrade_types'; @@ -21,7 +19,11 @@ import { ExperimentService } from '../ExperimentService'; import { FeatureFlagService } from '../FeatureFlagService'; import { MoocletExperimentService } from '../MoocletExperimentService'; import { SegmentService } from '../SegmentService'; -import { DeletionEligibilityService } from './DeletionEligibilityService'; +import { + DeletionEligibilityService, + DeletionEligibilityItem, + DeletionEligibilityResult, +} from './DeletionEligibilityService'; import { assertDeletionStateAllowed, DeletionBlockedError } from '../DeletionStateService'; // Admission budget: never abandon an in-flight deletion or claim it has been cancelled. @@ -50,7 +52,7 @@ export class BatchDeleteService { const deadline = performance.now() + BATCH_DELETE_START_BUDGET_MS; let preflight: DeletionEligibilityResult; try { - preflight = await this.eligibility[entity](ids, user); + preflight = await this.eligibility[entity](ids); } catch (error) { logger.error({ message: 'Batch deletion preflight failed', entity, error }); return { @@ -105,12 +107,7 @@ export class BatchDeleteService { } private preflightResult(item: DeletionEligibilityItem): BatchDeleteItemResult { - const outcome = - item.availability === 'not_found' - ? 'not_found' - : item.reasonCode === DeletionReasonCode.MISSING_PERMISSION - ? 'forbidden' - : 'ineligible'; + const outcome = item.availability === 'not_found' ? 'not_found' : 'ineligible'; return { id: item.id, outcome, reasonCode: item.reasonCode || DeletionReasonCode.ELIGIBILITY_UNAVAILABLE }; } diff --git a/packages/backend/src/api/services/batch/DeletionEligibilityService.ts b/packages/backend/src/api/services/batch/DeletionEligibilityService.ts index 08f2c68c8..927851352 100644 --- a/packages/backend/src/api/services/batch/DeletionEligibilityService.ts +++ b/packages/backend/src/api/services/batch/DeletionEligibilityService.ts @@ -1,27 +1,32 @@ import { Inject, Service } from 'typedi'; import { DataSource, In } from 'typeorm'; -import { UnauthorizedError } from 'routing-controllers'; import { - DeletionEligibilityItem, - DeletionEligibilityResult, DeletionReasonCode, - getExperimentDeletionState, getExperimentDeletionReason, getFlagDeletionReason, - hasBatchDeletePermission, SEGMENT_STATUS, SEGMENT_TYPE, } from 'upgrade_types'; import { InjectDataSource } from '../../../typeorm-typedi-extensions'; -import { UserDTO } from '../../DTO/UserDTO'; import { Experiment } from '../../models/Experiment'; import { FeatureFlag } from '../../models/FeatureFlag'; import { Segment } from '../../models/Segment'; import { SegmentService, SegmentWithStatus } from '../SegmentService'; +export interface DeletionEligibilityItem { + id: string; + availability: 'present' | 'not_found' | 'unavailable'; + canDelete: boolean; + reasonCode?: DeletionReasonCode; +} + +export interface DeletionEligibilityResult { + items: DeletionEligibilityItem[]; +} + type EligibilitySummary = Omit; -/** Read-only batch checks, also used by the initial batch-delete preflight. */ +/** Internal preflight for batch deletion. The caller checks permissions before querying. */ @Service() export class DeletionEligibilityService { constructor( @@ -29,44 +34,34 @@ export class DeletionEligibilityService { @Inject(() => SegmentService) private segmentService: SegmentService ) {} - public async experiments(ids: string[], user: UserDTO): Promise { - const permission = this.permissionReason(user); + public async experiments(ids: string[]): Promise { const rows = await this.dataSource.getRepository(Experiment).find({ where: { id: In(ids) }, - select: { id: true, name: true, state: true }, - }); - return this.result(ids, rows, (row) => { - const state = getExperimentDeletionState(row.state); - return { - availability: 'present', - name: row.name, - stateOrStatus: state, - reasonCode: permission || getExperimentDeletionReason(row.state), - }; + select: { id: true, state: true }, }); + return this.result(ids, rows, (row) => ({ + availability: 'present', + reasonCode: getExperimentDeletionReason(row.state), + })); } - public async flags(ids: string[], user: UserDTO): Promise { - const permission = this.permissionReason(user); + public async flags(ids: string[]): Promise { const rows = await this.dataSource.getRepository(FeatureFlag).find({ where: { id: In(ids) }, - select: { id: true, name: true, status: true }, + select: { id: true, status: true }, }); return this.result(ids, rows, (row) => ({ availability: 'present', - name: row.name, - stateOrStatus: row.status, - reasonCode: permission || getFlagDeletionReason(row.status), + reasonCode: getFlagDeletionReason(row.status), })); } - public async segments(ids: string[], user: UserDTO): Promise { - const permission = this.permissionReason(user, true); + public async segments(ids: string[]): Promise { const rows = await this.dataSource .getRepository(Segment) .createQueryBuilder('segment') .leftJoin('segment.subSegments', 'child') - .select(['segment.id', 'segment.name', 'segment.type', 'child.id']) + .select(['segment.id', 'segment.type', 'child.id']) .where('segment.id IN (:...ids)', { ids }) .getMany(); const publicRows = rows.filter((row) => row.type === SEGMENT_TYPE.PUBLIC); @@ -76,35 +71,22 @@ export class DeletionEligibilityService { : []; const statusById = new Map(statuses.map((row) => [row.id, row.status])); return this.result(ids, rows, (row) => { - // Private list internals are not part of the ordinary public-segment selection response. - if (row.type === SEGMENT_TYPE.PRIVATE) { - return { availability: 'unavailable', reasonCode: DeletionReasonCode.PROTECTED_SEGMENT_TYPE }; + if (row.type !== SEGMENT_TYPE.PUBLIC) { + return { availability: 'present', reasonCode: DeletionReasonCode.PROTECTED_SEGMENT_TYPE }; } - const status = row.type === SEGMENT_TYPE.GLOBAL_EXCLUDE ? SEGMENT_STATUS.EXCLUDED : statusById.get(row.id); + const status = statusById.get(row.id); return { availability: status ? 'present' : 'unavailable', - name: row.name, - segmentType: row.type, - stateOrStatus: status, reasonCode: - permission || - (row.type !== SEGMENT_TYPE.PUBLIC - ? DeletionReasonCode.PROTECTED_SEGMENT_TYPE - : status === SEGMENT_STATUS.UNUSED + status === SEGMENT_STATUS.UNUSED ? undefined : status === SEGMENT_STATUS.USED ? DeletionReasonCode.SEGMENT_IN_USE - : DeletionReasonCode.ELIGIBILITY_UNAVAILABLE), + : DeletionReasonCode.ELIGIBILITY_UNAVAILABLE, }; }); } - private permissionReason(user: UserDTO, segments = false): DeletionReasonCode | undefined { - if (!user) throw new UnauthorizedError('A current user is required'); - const allowed = hasBatchDeletePermission(user.role, segments ? 'segments' : 'experiments'); - return allowed ? undefined : DeletionReasonCode.MISSING_PERMISSION; - } - private result( ids: string[], rows: T[], @@ -119,6 +101,6 @@ export class DeletionEligibilityService { const summary = summarize(row); return { id, ...summary, canDelete: summary.availability === 'present' && !summary.reasonCode }; }); - return { items, allDeletable: items.length > 0 && items.every((item) => item.canDelete) }; + return { items }; } } diff --git a/packages/backend/test/integration/BatchDelete/index.ts b/packages/backend/test/integration/BatchDelete/index.ts index 0106b833c..2938eb1dc 100644 --- a/packages/backend/test/integration/BatchDelete/index.ts +++ b/packages/backend/test/integration/BatchDelete/index.ts @@ -227,8 +227,8 @@ export function registerBatchDeleteTests(connections: () => [DataSource, DataSou const [before, row, after] = await create(entity, 3); const service = Container.get(DeletionEligibilityService); const original = service[entity].bind(service); - jest.spyOn(service, entity).mockImplementationOnce(async (ids, user) => { - const result = await original(ids, user); + jest.spyOn(service, entity).mockImplementationOnce(async (ids) => { + const result = await original(ids); // A separate connection commits a normal edit after the bulk read, before deletion begins. if (entity === 'experiments') await writer.getRepository(Experiment).update(row.id, { state: EXPERIMENT_STATE.DRAFT }); diff --git a/packages/backend/test/integration/DeletionEligibility/index.ts b/packages/backend/test/integration/DeletionEligibility/index.ts deleted file mode 100644 index 6dd88316b..000000000 --- a/packages/backend/test/integration/DeletionEligibility/index.ts +++ /dev/null @@ -1,345 +0,0 @@ -import { randomUUID } from 'crypto'; -import { Application } from 'express'; -import request from 'supertest'; -import Container from 'typedi'; -import { createExpressServer } from 'routing-controllers'; -import { DataSource } from 'typeorm'; -import { - ASSIGNMENT_UNIT, - DeletionReasonCode, - EXPERIMENT_STATE, - FEATURE_FLAG_STATUS, - POST_EXPERIMENT_RULE, - SEGMENT_TYPE, - STANDARD_LIST_TYPE, - SYSTEM_USER_EMAIL, - UserRole, -} from 'upgrade_types'; -import { ExperimentController } from '../../../src/api/controllers/ExperimentController'; -import { FeatureFlagsController } from '../../../src/api/controllers/FeatureFlagController'; -import { SegmentController } from '../../../src/api/controllers/SegmentController'; -import { ErrorHandlerMiddleware } from '../../../src/api/middlewares/ErrorHandlerMiddleware'; -import { LogMiddleware } from '../../../src/api/middlewares/LogMiddleware'; -import { Experiment } from '../../../src/api/models/Experiment'; -import { ExperimentSegmentInclusion } from '../../../src/api/models/ExperimentSegmentInclusion'; -import { FeatureFlag } from '../../../src/api/models/FeatureFlag'; -import { FeatureFlagSegmentExclusion } from '../../../src/api/models/FeatureFlagSegmentExclusion'; -import { IndividualForSegment } from '../../../src/api/models/IndividualForSegment'; -import { Segment } from '../../../src/api/models/Segment'; -import { User } from '../../../src/api/models/User'; -import { DeletionEligibilityService } from '../../../src/api/services/batch/DeletionEligibilityService'; -import { SegmentService } from '../../../src/api/services/SegmentService'; -import { currentUserChecker } from '../../../src/auth/currentUserChecker'; -import { env } from '../../../src/env'; -import { iocLoader } from '../../../src/loaders/iocLoader'; - -const entities = ['experiments', 'flags', 'segments'] as const; -type Entity = (typeof entities)[number]; -const route = (entity: Entity) => `/api/${entity}/deletion-eligibility`; - -export function registerDeletionEligibilityTests(connections: () => [DataSource, DataSource]) { - describe('Deletion eligibility', () => { - let db: DataSource; - let app: Application; - let originalAuth: boolean; - beforeAll(() => { - iocLoader(); - // The global unit-test setup mocks auth. These HTTP tests use the real checker and seeded users. - const { authorizationChecker } = jest.requireActual('../../../src/auth/authorizationChecker'); - app = createExpressServer({ - routePrefix: '/api', - controllers: [ExperimentController, FeatureFlagsController, SegmentController], - middlewares: [LogMiddleware, ErrorHandlerMiddleware], - classTransformer: true, - validation: { whitelist: true, validationError: { target: false, value: false } }, - defaultErrorHandler: false, - authorizationChecker: authorizationChecker(), - currentUserChecker, - }); - }); - beforeEach(() => { - [db] = connections(); - originalAuth = env.google.authTokenRequired; - env.google.authTokenRequired = false; - }); - afterEach(() => { - env.google.authTokenRequired = originalAuth; - jest.restoreAllMocks(); - }); - - const create = async (entity: Entity, count = 1) => { - const rows = Array.from({ length: count }, (_, index) => ({ - id: randomUUID(), - name: `Eligibility ${index}`, - context: 'home', - })); - if (entity === 'experiments') { - await db.getRepository(Experiment).insert( - rows.map((row) => ({ - ...row, - context: [row.context], - description: '', - assignmentUnit: ASSIGNMENT_UNIT.INDIVIDUAL, - postExperimentRule: POST_EXPERIMENT_RULE.CONTINUE, - })) - ); - } else if (entity === 'flags') { - await db.getRepository(FeatureFlag).insert( - rows.map((row) => ({ - ...row, - context: [row.context], - key: row.id, - description: '', - })) - ); - } else { - await db.getRepository(Segment).insert(rows.map((row) => ({ ...row, type: SEGMENT_TYPE.PUBLIC }))); - } - return rows; - }; - const list = async (name: string, child: { id: string }) => { - const row = { - id: randomUUID(), - name, - context: 'home', - type: SEGMENT_TYPE.PRIVATE, - listType: STANDARD_LIST_TYPE.SEGMENT, - }; - await db.getRepository(Segment).insert(row); - // Match the existing Add List service's relation write; avoid cascading saves of partial children. - await db.createQueryBuilder().relation(Segment, 'subSegments').of(row.id).add(child.id); - return row; - }; - - test.each(entities)('%s rejects invalid IDs before the eligibility service runs', async (entity) => { - const service = Container.get(DeletionEligibilityService); - const spy = jest.spyOn(service, entity); - const id = randomUUID(); - const invalid = [ - {}, - { ids: [] }, - { ids: null }, - { ids: id }, - { ids: [42] }, - { ids: ['bad-id'] }, - { ids: [id, 'bad-id'] }, - { ids: [id, id] }, - { ids: [id, id.toUpperCase()] }, - ]; - for (const body of invalid) await request(app).post(route(entity)).send(body).expect(400); - expect(spy).not.toHaveBeenCalled(); - }); - - test.each(entities)('%s rejects unauthenticated requests and a missing current user', async (entity) => { - const spy = jest.spyOn(Container.get(DeletionEligibilityService), entity); - env.google.authTokenRequired = true; - await request(app) - .post(route(entity)) - .send({ ids: [randomUUID()] }) - .expect(401); - env.google.authTokenRequired = false; - await db.getRepository(User).delete({ email: SYSTEM_USER_EMAIL }); - await request(app) - .post(route(entity)) - .send({ ids: [randomUUID()] }) - .expect(401); - expect(spy).not.toHaveBeenCalled(); - }); - - test.each(Object.values(UserRole))('uses server permissions for %s', async (role) => { - await db.getRepository(User).update(SYSTEM_USER_EMAIL, { role }); - for (const entity of entities) { - const [row] = await create(entity); - const { body } = await request(app) - .post(route(entity)) - .send({ - ids: [row.id], - role: UserRole.ADMIN, - canDelete: true, - stateOrStatus: 'unused', - }) - .expect(200); - const allowed = - role === UserRole.ADMIN || - role === UserRole.CREATOR || - (role === UserRole.USER_MANAGER && entity === 'segments'); - expect(body.allDeletable).toBe(allowed); - expect(body.items[0]).toMatchObject({ - id: row.id, - name: row.name, - availability: 'present', - canDelete: allowed, - }); - expect(body.items[0].reasonCode).toBe(allowed ? undefined : DeletionReasonCode.MISSING_PERMISSION); - } - }); - - test('normalizes experiment aliases and matches all existing menu states', async () => { - const states: [EXPERIMENT_STATE, string, boolean][] = [ - [EXPERIMENT_STATE.INACTIVE, 'inactive', true], - [EXPERIMENT_STATE.ENROLLING, 'running', true], - [EXPERIMENT_STATE.ENROLLMENT_COMPLETE, 'paused', true], - [EXPERIMENT_STATE.CANCELLED, 'completed', true], - [EXPERIMENT_STATE.ARCHIVED, 'archived', true], - [EXPERIMENT_STATE.DRAFT, 'draft', false], - [EXPERIMENT_STATE.PREVIEW, 'preview', false], - [EXPERIMENT_STATE.SCHEDULED, 'scheduled', false], - ]; - const rows = await create('experiments', states.length); - for (let i = 0; i < rows.length; i++) - await db.getRepository(Experiment).update(rows[i].id, { state: states[i][0] }); - const { body } = await request(app) - .post(route('experiments')) - .send({ ids: rows.map((row) => row.id) }) - .expect(200); - expect(body.allDeletable).toBe(false); - body.items.forEach((item, i) => { - expect(item.stateOrStatus).toBe(states[i][1]); - expect(item.canDelete).toBe(states[i][2]); - expect(item.reasonCode).toBe(states[i][2] ? undefined : DeletionReasonCode.EXPERIMENT_STATE_UNSUPPORTED); - }); - }); - - test('allows disabled/archived flags and observes a later enablement', async () => { - const rows = await create('flags', 3); - await db.getRepository(FeatureFlag).update(rows[1].id, { status: FEATURE_FLAG_STATUS.ARCHIVED }); - await db.getRepository(FeatureFlag).update(rows[2].id, { status: FEATURE_FLAG_STATUS.ENABLED }); - const ids = rows.map((row) => row.id); - const first = await request(app).post(route('flags')).send({ ids }).expect(200); - expect(first.body.items.map((item) => item.canDelete)).toEqual([true, true, false]); - expect(first.body.items[2].reasonCode).toBe(DeletionReasonCode.FEATURE_FLAG_ENABLED); - await db.getRepository(FeatureFlag).update(rows[0].id, { status: FEATURE_FLAG_STATUS.ENABLED }); - const second = await request(app) - .post(route('flags')) - .send({ ids: [ids[0]] }) - .expect(200); - expect(second.body.allDeletable).toBe(false); - expect(second.body.items[0].reasonCode).toBe(DeletionReasonCode.FEATURE_FLAG_ENABLED); - }); - - test.each(entities)('%s preserves input order and reports missing IDs without exposing details', async (entity) => { - const rows = await create(entity, 2); - const ids = [rows[1].id.toUpperCase(), randomUUID(), rows[0].id]; - const { body } = await request(app).post(route(entity)).send({ ids }).expect(200); - expect(body.items.map((item) => item.id)).toEqual(ids); - expect(body.items.map((item) => item.canDelete)).toEqual([true, false, true]); - expect(body.items[1]).toEqual({ - id: ids[1], - availability: 'not_found', - canDelete: false, - reasonCode: 'not_found', - }); - for (const item of body.items) { - expect( - Object.keys(item).every((key) => - ['id', 'availability', 'name', 'stateOrStatus', 'segmentType', 'canDelete', 'reasonCode'].includes(key) - ) - ).toBe(true); - } - expect(body.allDeletable).toBe(false); - }); - - test('includes hidden Used selections using the current UI private-list structure', async () => { - const [unused, child, parent] = await create('segments', 3); - // Same topology as Add List: public parent -> private Segment list -> public child. - const childList = await list('Child list', child); - await db.createQueryBuilder().relation(Segment, 'subSegments').of(parent.id).add(childList.id); - const ownerList = await list('Experiment list', parent); - const [experiment] = await create('experiments'); - await db - .getRepository(ExperimentSegmentInclusion) - .insert({ experimentId: experiment.id, segmentId: ownerList.id }); - await db.getRepository(IndividualForSegment).insert({ segmentId: unused.id, userId: 'do-not-return-members' }); - const spy = jest.spyOn(Container.get(SegmentService), 'getSegmentStatus'); - const { body } = await request(app) - .post(route('segments')) - .send({ - ids: [unused.id, child.id], - searchParams: { key: 'name', string: unused.name }, - }) - .expect(200); - expect(body.items.map((item) => item.canDelete)).toEqual([true, false]); - expect(body.items[1].reasonCode).toBe(DeletionReasonCode.SEGMENT_IN_USE); - expect(body.allDeletable).toBe(false); - expect(spy).toHaveBeenCalledTimes(1); - expect(spy.mock.calls[0][0].map((row) => row.id).sort()).toEqual([unused.id, child.id].sort()); - expect(await db.getRepository(IndividualForSegment).countBy({ segmentId: unused.id })).toBe(1); - }); - - test('preserves archived-experiment and disabled-flag reference rules', async () => { - const [archivedChild, flagChild] = await create('segments', 2); - const [experiment] = await create('experiments'); - await db.getRepository(Experiment).update(experiment.id, { state: EXPERIMENT_STATE.ARCHIVED }); - const experimentList = await list('Archived owner list', archivedChild); - await db - .getRepository(ExperimentSegmentInclusion) - .insert({ experimentId: experiment.id, segmentId: experimentList.id }); - const [flag] = await create('flags'); - const flagList = await list('Disabled flag list', flagChild); - await db.getRepository(FeatureFlagSegmentExclusion).insert({ - featureFlagId: flag.id, - segmentId: flagList.id, - enabled: false, - listType: STANDARD_LIST_TYPE.SEGMENT, - }); - const { body } = await request(app) - .post(route('segments')) - .send({ ids: [archivedChild.id, flagChild.id] }) - .expect(200); - expect(body.items.map((item) => item.canDelete)).toEqual([true, false]); - expect(body.items[1].reasonCode).toBe(DeletionReasonCode.SEGMENT_IN_USE); - }); - - test('rejects global/private segments and omits private-list metadata', async () => { - const rows = await create('segments', 2); - await db.getRepository(Segment).update(rows[0].id, { type: SEGMENT_TYPE.PRIVATE }); - await db.getRepository(Segment).update(rows[1].id, { type: SEGMENT_TYPE.GLOBAL_EXCLUDE }); - const spy = jest.spyOn(Container.get(SegmentService), 'getSegmentStatus'); - const { body } = await request(app) - .post(route('segments')) - .send({ ids: rows.map((row) => row.id) }) - .expect(200); - expect(body.items[0]).toEqual({ - id: rows[0].id, - availability: 'unavailable', - canDelete: false, - reasonCode: DeletionReasonCode.PROTECTED_SEGMENT_TYPE, - }); - expect(body.items[1]).toMatchObject({ - stateOrStatus: 'excluded', - canDelete: false, - reasonCode: DeletionReasonCode.PROTECTED_SEGMENT_TYPE, - }); - expect(body.allDeletable).toBe(false); - expect(spy).not.toHaveBeenCalled(); - }); - - test.each(entities.flatMap((entity) => [20, 100, 500].map((count) => ({ entity, count }))))( - '$entity accepts $count IDs in one read-only HTTP request without per-ID queries', - async ({ entity, count }) => { - const rows = await create(entity, count); - const ids = rows.map((row) => row.id).reverse(); - const querySpy = jest.spyOn(db.logger, 'logQuery'); - const service = Container.get(SegmentService); - const statusSpy = jest.spyOn(service, 'getSegmentStatus'); - const detailsSpy = jest.spyOn(service, 'getSingleSegmentWithStatus'); - const membersSpy = jest.spyOn(service, 'getSegmentByIds'); - const { body } = await request(app).post(route(entity)).send({ ids }).expect(200); - expect(body.allDeletable).toBe(true); - expect(body.items.map((item) => item.id)).toEqual(ids); - const queries = querySpy.mock.calls.map(([sql]) => sql); - expect(queries.filter((sql) => /^(INSERT|UPDATE|DELETE|ALTER|CREATE)|FOR (UPDATE|SHARE)/i.test(sql))).toEqual( - [] - ); - // Count selection reads, not background recomputation started by the global-segment seed. - const selectionQueries = querySpy.mock.calls.filter(([, parameters]) => - parameters?.some((parameter) => ids.includes(parameter)) - ); - expect(selectionQueries).toHaveLength(1); - expect(statusSpy).toHaveBeenCalledTimes(entity === 'segments' ? 1 : 0); - expect(detailsSpy).not.toHaveBeenCalled(); - expect(membersSpy).not.toHaveBeenCalled(); - } - ); - }); -} diff --git a/packages/backend/test/integration/index.test.ts b/packages/backend/test/integration/index.test.ts index 0200dfa04..bbb138b23 100644 --- a/packages/backend/test/integration/index.test.ts +++ b/packages/backend/test/integration/index.test.ts @@ -125,7 +125,6 @@ import { FeatureFlagInclusionExclusion } from './FeatureFlags'; import { ListValueFiltering } from './ListValueFiltering'; import { FeatureFlagDeleteCleanup, SegmentDeleteCleanup } from './DeleteCleanup'; import { registerSegmentDeletionGuardTests } from './SegmentDeletionGuard'; -import { registerDeletionEligibilityTests } from './DeletionEligibility'; import { registerBatchDeleteTests } from './BatchDelete'; describe('Integration Tests', () => { @@ -137,7 +136,6 @@ describe('Integration Tests', () => { let defaultConnection: DataSource; let exportConnection: DataSource; registerSegmentDeletionGuardTests(() => [defaultConnection, exportConnection]); - registerDeletionEligibilityTests(() => [defaultConnection, exportConnection]); registerBatchDeleteTests(() => [defaultConnection, exportConnection]); beforeAll(async () => { configureLogger(); diff --git a/packages/backend/test/unit/controllers/ExperimentController.test.ts b/packages/backend/test/unit/controllers/ExperimentController.test.ts index fc5493b9e..96138b37e 100644 --- a/packages/backend/test/unit/controllers/ExperimentController.test.ts +++ b/packages/backend/test/unit/controllers/ExperimentController.test.ts @@ -1,4 +1,3 @@ -import { DeletionEligibilityService } from '../../../src/api/services/batch/DeletionEligibilityService'; import { BatchDeleteService } from '../../../src/api/services/batch/BatchDeleteService'; import app from '../../utils/expressApp'; import request from 'supertest'; @@ -36,7 +35,6 @@ describe('Experiment Controller Testing', () => { configureLogger(); routingUseContainer(Container); classValidatorUseContainer(Container); - Container.set(DeletionEligibilityService, {} as DeletionEligibilityService); Container.set(BatchDeleteService, {} as BatchDeleteService); // set mock container diff --git a/packages/backend/test/unit/controllers/FeatureFlagController.test.ts b/packages/backend/test/unit/controllers/FeatureFlagController.test.ts index d73c45761..956ff305f 100644 --- a/packages/backend/test/unit/controllers/FeatureFlagController.test.ts +++ b/packages/backend/test/unit/controllers/FeatureFlagController.test.ts @@ -1,4 +1,3 @@ -import { DeletionEligibilityService } from '../../../src/api/services/batch/DeletionEligibilityService'; import { BatchDeleteService } from '../../../src/api/services/batch/BatchDeleteService'; import app from '../../utils/expressApp'; import request from 'supertest'; @@ -20,7 +19,6 @@ describe('Feature Flag Controller Testing', () => { configureLogger(); routingUseContainer(Container); classValidatorUseContainer(Container); - Container.set(DeletionEligibilityService, {} as DeletionEligibilityService); Container.set(BatchDeleteService, {} as BatchDeleteService); Container.set(FeatureFlagService, new FeatureFlagServiceMock()); diff --git a/packages/backend/test/unit/controllers/SegmentController.test.ts b/packages/backend/test/unit/controllers/SegmentController.test.ts index c57a45ceb..6b9ee6dbc 100644 --- a/packages/backend/test/unit/controllers/SegmentController.test.ts +++ b/packages/backend/test/unit/controllers/SegmentController.test.ts @@ -1,4 +1,3 @@ -import { DeletionEligibilityService } from '../../../src/api/services/batch/DeletionEligibilityService'; import { BatchDeleteService } from '../../../src/api/services/batch/BatchDeleteService'; import app from '../../utils/expressApp'; import request from 'supertest'; @@ -16,7 +15,6 @@ describe('Segment Controller Testing', () => { configureLogger(); routingUseContainer(Container); classValidatorUseContainer(Container); - Container.set(DeletionEligibilityService, {} as DeletionEligibilityService); Container.set(BatchDeleteService, {} as BatchDeleteService); // set mock container diff --git a/packages/backend/test/unit/services/batch/BatchDeleteService.test.ts b/packages/backend/test/unit/services/batch/BatchDeleteService.test.ts index cdfd16e1e..1a1dd591a 100644 --- a/packages/backend/test/unit/services/batch/BatchDeleteService.test.ts +++ b/packages/backend/test/unit/services/batch/BatchDeleteService.test.ts @@ -1,9 +1,12 @@ import { randomUUID } from 'crypto'; import { performance } from 'perf_hooks'; import { DataSource, QueryRunner } from 'typeorm'; -import { BatchDeleteEntity, DeletionEligibilityResult, DeletionReasonCode, UserRole } from 'upgrade_types'; +import { BatchDeleteEntity, DeletionReasonCode, UserRole } from 'upgrade_types'; import { BatchDeleteService } from '../../../../src/api/services/batch/BatchDeleteService'; -import { DeletionEligibilityService } from '../../../../src/api/services/batch/DeletionEligibilityService'; +import { + DeletionEligibilityService, + DeletionEligibilityResult, +} from '../../../../src/api/services/batch/DeletionEligibilityService'; import { ExperimentService } from '../../../../src/api/services/ExperimentService'; import { FeatureFlagService } from '../../../../src/api/services/FeatureFlagService'; import { SegmentService } from '../../../../src/api/services/SegmentService'; @@ -42,7 +45,6 @@ describe('BatchDeleteService transaction outcomes', () => { env.mooclets.enabled = false; jest.spyOn(segmentGuard, 'assertSegmentDeletionAllowed').mockResolvedValue(undefined); const eligible = async (requested: string[]): Promise => ({ - allDeletable: true, items: requested.map((id) => ({ id, availability: 'present', canDelete: true })), }); eligibility = { experiments: jest.fn(eligible), flags: jest.fn(eligible), segments: jest.fn(eligible) }; @@ -131,7 +133,6 @@ describe('BatchDeleteService transaction outcomes', () => { test('skips ineligible and missing selections while deleting eligible items', async () => { eligibility.segments.mockResolvedValue({ - allDeletable: false, items: [ { id: ids[0], canDelete: true, availability: 'present' }, { id: ids[1], canDelete: false, availability: 'present', reasonCode: DeletionReasonCode.SEGMENT_IN_USE }, @@ -160,7 +161,6 @@ describe('BatchDeleteService transaction outcomes', () => { test('does not start transactions when every selection is ineligible', async () => { eligibility.flags.mockResolvedValue({ - allDeletable: false, items: ids.map((id) => ({ id, canDelete: false, diff --git a/packages/backend/test/unit/services/batch/DeletionEligibilityService.test.ts b/packages/backend/test/unit/services/batch/DeletionEligibilityService.test.ts index fa3be2cc1..bc424aef4 100644 --- a/packages/backend/test/unit/services/batch/DeletionEligibilityService.test.ts +++ b/packages/backend/test/unit/services/batch/DeletionEligibilityService.test.ts @@ -1,21 +1,13 @@ import { randomUUID } from 'crypto'; import { DataSource } from 'typeorm'; -import { - DeletionReasonCode, - EXPERIMENT_STATE, - FEATURE_FLAG_STATUS, - SEGMENT_STATUS, - SEGMENT_TYPE, - UserRole, -} from 'upgrade_types'; +import { DeletionReasonCode, EXPERIMENT_STATE, FEATURE_FLAG_STATUS, SEGMENT_STATUS, SEGMENT_TYPE } from 'upgrade_types'; import { DeletionEligibilityService } from '../../../../src/api/services/batch/DeletionEligibilityService'; import { SegmentService } from '../../../../src/api/services/SegmentService'; const methods = ['experiments', 'flags', 'segments'] as const; -describe('DeletionEligibilityService failure handling', () => { +describe('Internal batch deletion eligibility', () => { const id = randomUUID(); - const admin = { email: 'test@example.com', firstName: 'Test', lastName: 'User', role: UserRole.ADMIN }; let service: DeletionEligibilityService; let find: jest.Mock; let getMany: jest.Mock; @@ -46,52 +38,74 @@ describe('DeletionEligibilityService failure handling', () => { ); }); - test.each(methods)('%s requires a current user before querying', async (method) => { - await expect(service[method]([id], undefined)).rejects.toMatchObject({ httpCode: 401 }); - expect(getRepository).not.toHaveBeenCalled(); + test('preserves experiment state aliases and reports missing IDs in request order', async () => { + const states = [ + EXPERIMENT_STATE.INACTIVE, + EXPERIMENT_STATE.ENROLLING, + EXPERIMENT_STATE.ENROLLMENT_COMPLETE, + EXPERIMENT_STATE.CANCELLED, + EXPERIMENT_STATE.ARCHIVED, + EXPERIMENT_STATE.DRAFT, + EXPERIMENT_STATE.PREVIEW, + EXPERIMENT_STATE.SCHEDULED, + ]; + const rows = states.map((state) => ({ id: randomUUID(), state })); + find.mockResolvedValue([...rows].reverse()); + const ids = [...rows.map((row) => row.id.toUpperCase()), randomUUID()]; + const result = await service.experiments(ids); + expect(result.items.map((item) => item.id)).toEqual(ids); + expect(result.items.map((item) => item.canDelete)).toEqual([ + true, + true, + true, + true, + true, + false, + false, + false, + false, + ]); + expect(result.items[result.items.length - 1].reasonCode).toBe(DeletionReasonCode.NOT_FOUND); }); - test.each(methods)('%s denies missing and unknown roles', async (method) => { - for (const role of [undefined, null, 'unknown' as UserRole]) { - const result = await service[method]([id], { ...admin, role }); - expect(result.allDeletable).toBe(false); - expect(result.items[0]).toMatchObject({ - availability: 'present', - canDelete: false, - reasonCode: DeletionReasonCode.MISSING_PERMISSION, - }); - } + test('allows disabled and archived flags while rejecting enabled flags', async () => { + const rows = [FEATURE_FLAG_STATUS.DISABLED, FEATURE_FLAG_STATUS.ARCHIVED, FEATURE_FLAG_STATUS.ENABLED].map( + (status) => ({ id: randomUUID(), status }) + ); + find.mockResolvedValue(rows); + const result = await service.flags(rows.map((row) => row.id)); + expect(result.items.map((item) => item.canDelete)).toEqual([true, true, false]); + expect(result.items[2].reasonCode).toBe(DeletionReasonCode.FEATURE_FLAG_ENABLED); }); test.each(methods)('%s propagates database failure instead of reporting absence', async (method) => { const failure = new Error('Database unavailable'); find.mockRejectedValue(failure); getMany.mockRejectedValue(failure); - await expect(service[method]([id], admin)).rejects.toBe(failure); + await expect(service[method]([id])).rejects.toBe(failure); expect(getSegmentStatus).not.toHaveBeenCalled(); }); test('propagates a failed shared segment status read', async () => { const failure = new Error('Status read failed'); getSegmentStatus.mockRejectedValue(failure); - await expect(service.segments([id], admin)).rejects.toBe(failure); + await expect(service.segments([id])).rejects.toBe(failure); }); test('does not declare an existing segment deletable when its derived status is unavailable', async () => { getSegmentStatus.mockResolvedValue({ segmentsData: [] }); - const result = await service.segments([id], admin); + const result = await service.segments([id]); expect(result.items[0]).toMatchObject({ id, availability: 'unavailable', canDelete: false, reasonCode: DeletionReasonCode.ELIGIBILITY_UNAVAILABLE, }); - expect(result.allDeletable).toBe(false); }); test('does not run global status reads when no selected segment exists', async () => { getMany.mockResolvedValue([]); - const result = await service.segments([id], admin); + const result = await service.segments([id]); expect(result.items[0]).toMatchObject({ availability: 'not_found', canDelete: false }); expect(getSegmentStatus).not.toHaveBeenCalled(); }); diff --git a/packages/frontend/projects/upgrade/src/app/core/api-endpoints.constants.ts b/packages/frontend/projects/upgrade/src/app/core/api-endpoints.constants.ts index c26351c95..85f520c84 100644 --- a/packages/frontend/projects/upgrade/src/app/core/api-endpoints.constants.ts +++ b/packages/frontend/projects/upgrade/src/app/core/api-endpoints.constants.ts @@ -5,11 +5,8 @@ import { APIEndpoints } from '../../environments/environment-types'; * These are relative paths that will be prepended with the environment's apiBaseUrl by the HTTP interceptor. */ export const API_ENDPOINTS: APIEndpoints = { - experimentsDeletionEligibility: '/experiments/deletion-eligibility', experimentsBatchDelete: '/experiments/batch-delete', - flagsDeletionEligibility: '/flags/deletion-eligibility', flagsBatchDelete: '/flags/batch-delete', - segmentsDeletionEligibility: '/segments/deletion-eligibility', segmentsBatchDelete: '/segments/batch-delete', getAllExperiments: '/experiments/paginated', createNewExperiments: '/experiments', diff --git a/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.actions.ts b/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.actions.ts index 7bfbac29b..456d606e9 100644 --- a/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.actions.ts +++ b/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.actions.ts @@ -1,5 +1,5 @@ import { createAction, props } from '@ngrx/store'; -import { BatchDeleteResult, DeletionEligibilityResult } from 'upgrade_types'; +import { BatchDeleteResult } from 'upgrade_types'; import { BatchDeleteSnapshot, RootSelectionItem } from './batch-actions.models'; export function createBatchActions(source: string) { @@ -20,10 +20,6 @@ export function createBatchActions(source: string) { `${prefix} Delete Request Failed`, props<{ operationId: string; status: number }>() ), - reconciliationCompleted: createAction( - `${prefix} Reconciliation Completed`, - props<{ operationId: string; result: DeletionEligibilityResult | null; status?: number }>() - ), listRequested: createAction(`${prefix} List Requested`, props<{ requestId: string }>()), listFailed: createAction(`${prefix} List Failed`, props<{ requestId: string }>()), }; diff --git a/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.effects.ts b/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.effects.ts index 967c3e072..00b20d985 100644 --- a/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.effects.ts +++ b/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.effects.ts @@ -12,13 +12,12 @@ import { throwIfEmpty, withLatestFrom, } from 'rxjs/operators'; -import { BatchDeleteResult, DeletionEligibilityResult, DeletionReasonCode } from 'upgrade_types'; +import { BatchDeleteResult, DeletionReasonCode } from 'upgrade_types'; import { RootBatchActions } from './batch-actions.actions'; import { RootBatchState, newBatchRequestId } from './batch-actions.models'; -import { validateBatchResponse, validateEligibilityResponse } from './batch-actions.helpers'; +import { validateBatchResponse } from './batch-actions.helpers'; export interface BatchDataSource { - checkDeletionEligibility(ids: string[]): Observable; batchDelete(ids: string[]): Observable; } @@ -73,37 +72,6 @@ export function batchDeleteEffect( ); } -export function reconcileBatchEffect( - events: Observable, - state$: Observable, - actions: RootBatchActions, - data: BatchDataSource -) { - return events.pipe( - filter((action) => action.type === actions.batchDeleteCompleted.type), - withLatestFrom(state$), - filter( - ([action, state]) => - state.operation?.status === 'reconciling' && - state.operation.snapshot.operationId === (action as ReturnType).operationId - ), - exhaustMap(([, state]) => { - const operationId = state.operation.snapshot.operationId; - const ids = state.operation.snapshot.items.map((item) => item.id); - return defer(() => data.checkDeletionEligibility(ids)).pipe( - throwIfEmpty(), - map((result) => - actions.reconciliationCompleted({ operationId, result: validateEligibilityResponse(result, ids) }) - ), - catchError((error) => - of(actions.reconciliationCompleted({ operationId, result: null, status: error?.status })) - ), - takeUntil(state$.pipe(filter((current) => current.userEmail !== state.userEmail || !current.operation))) - ); - }) - ); -} - export function batchFinishedEffect( events: Observable, state$: Observable, @@ -112,11 +80,7 @@ export function batchFinishedEffect( ) { return events.pipe( filter((action) => - [ - actions.batchDeleteCompleted.type, - actions.batchDeleteRequestFailed.type, - actions.reconciliationCompleted.type, - ].some((type) => type === action.type) + [actions.batchDeleteCompleted.type, actions.batchDeleteRequestFailed.type].some((type) => type === action.type) ), withLatestFrom(state$), filter( diff --git a/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.helpers.spec.ts b/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.helpers.spec.ts index d732b7241..7877f222f 100644 --- a/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.helpers.spec.ts +++ b/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.helpers.spec.ts @@ -8,12 +8,7 @@ import { UserRole, } from 'upgrade_types'; import { createBatchActions } from './batch-actions.actions'; -import { - localDeletionReason, - selectionView, - validateBatchResponse, - validateEligibilityResponse, -} from './batch-actions.helpers'; +import { localDeletionReason, selectionView, validateBatchResponse } from './batch-actions.helpers'; import { RootBatchState, RootSelectionItem, initialRootBatchState } from './batch-actions.models'; import { reduceRootBatch } from './batch-actions.reducer'; @@ -122,8 +117,7 @@ describe('Root selection rules', () => { } ); - it('treats an incomplete or duplicate eligibility/deletion response as uncertain rather than silently removing rows', () => { - expect(() => validateEligibilityResponse({ items: [], allDeletable: true }, ['a'])).toThrow(); + it('treats an incomplete or duplicate deletion response as uncertain rather than silently removing rows', () => { expect(() => validateBatchResponse({ phase: 'executed', results: [{ id: 'a', outcome: 'deleted' }] }, ['a', 'b']) ).toThrow(); diff --git a/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.helpers.ts b/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.helpers.ts index a50cb7090..72108bc90 100644 --- a/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.helpers.ts +++ b/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.helpers.ts @@ -1,7 +1,6 @@ import { BatchDeleteEntity, BatchDeleteResult, - DeletionEligibilityResult, DeletionReasonCode, EXPERIMENT_STATE, FEATURE_FLAG_STATUS, @@ -61,30 +60,6 @@ export const confirmedRemovedIds = (result?: BatchDeleteResult) => result?.results.filter((item) => item.outcome === 'deleted' || item.outcome === 'not_found').map((item) => item.id) || []; -export function validateEligibilityResponse( - result: DeletionEligibilityResult, - ids: string[] -): DeletionEligibilityResult { - if ( - !result || - !Array.isArray(result.items) || - result.items.length !== ids.length || - new Set(result.items.map((item) => item.id)).size !== ids.length || - ids.some((id) => !result.items.some((item) => item.id === id)) || - result.items.some( - (item) => - !['present', 'not_found', 'unavailable'].includes(item.availability) || typeof item.canDelete !== 'boolean' - ) - ) { - throw new Error('Incomplete deletion eligibility response'); - } - return { - ...result, - allDeletable: - result.items.length > 0 && result.items.every((item) => item.availability === 'present' && item.canDelete), - }; -} - export function validateBatchResponse(result: BatchDeleteResult, ids: string[]): BatchDeleteResult { const outcomes = ['deleted', 'not_found', 'ineligible', 'forbidden', 'failed', 'unknown', 'not_attempted']; if ( @@ -103,14 +78,10 @@ export function validateBatchResponse(result: BatchDeleteResult, ids: string[]): export function batchResultCounts(state: RootBatchState) { const results = state.operation?.result?.results || []; - const absent = new Set([ - ...results.filter((item) => item.outcome === 'not_found').map((item) => item.id), - ...(state.operation?.reconciledAbsentIds || []), - ]); return { deleted: results.filter((item) => item.outcome === 'deleted').length, - absent: absent.size, - uncertain: results.some((item) => item.outcome === 'unknown') || !!state.operation?.reconciliationFailed, + absent: results.filter((item) => item.outcome === 'not_found').length, + uncertain: results.some((item) => item.outcome === 'unknown'), failed: results.filter((item) => ['failed', 'ineligible', 'forbidden'].includes(item.outcome)).length, notAttempted: results.filter((item) => item.outcome === 'not_attempted').length, hasErrors: results.some((item) => item.outcome !== 'deleted' || item.reasonCode), diff --git a/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.http.spec.ts b/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.http.spec.ts index ad54d6297..867ca729a 100644 --- a/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.http.spec.ts +++ b/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.http.spec.ts @@ -28,19 +28,13 @@ describe('Batch HTTP contracts', () => { ['experiments', ExperimentDataService], ['flags', FeatureFlagsDataService], ['segments', SegmentsDataService], - ] as const)('%s sends the complete selection in one read or delete request', (entity, token) => { + ] as const)('%s sends the complete selection in one deletion request', (entity, token) => { const service = TestBed.inject(token as typeof ExperimentDataService); for (const count of [20, 100, 500]) { const ids = Array.from( { length: count }, (_, index) => `00000000-0000-4000-8000-${String(index).padStart(12, '0')}` ); - service.checkDeletionEligibility(ids).subscribe(); - const read = http.expectOne(`/${entity}/deletion-eligibility`); - expect(read.request.method).toBe('POST'); - expect(read.request.body).toEqual({ ids }); - expect(read.request.context.get(SKIP_NAVIGATION_CANCEL)).toBe(true); - read.flush({ items: [], allDeletable: false }); service.batchDelete(ids).subscribe(); const deletion = http.expectOne(`/${entity}/batch-delete`); expect(deletion.request.method).toBe('POST'); diff --git a/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.integration.spec.ts b/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.integration.spec.ts index 1bd193656..af4b61e1f 100644 --- a/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.integration.spec.ts +++ b/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.integration.spec.ts @@ -1,5 +1,4 @@ import { TestBed, fakeAsync, tick } from '@angular/core/testing'; -import { HttpErrorResponse, HttpRequest } from '@angular/common/http'; import { TranslateModule, TranslateService } from '@ngx-translate/core'; import { Actions, getEffectsMetadata } from '@ngrx/effects'; import { Action, ScannedActionsSubject, Store, StoreModule } from '@ngrx/store'; @@ -26,7 +25,6 @@ import { FeatureFlagsService } from '../feature-flags/feature-flags.service'; import { FeatureFlagRootSectionCardComponent } from '../../features/dashboard/feature-flags/pages/feature-flag-root-page/feature-flag-root-page-content/feature-flag-root-section-card/feature-flag-root-section-card.component'; import { SegmentsEffects } from '../segments/store/segments.effects'; import { actionLogoutStart, actionSetUserInfo } from '../auth/store/auth.actions'; -import { HttpErrorInterceptor } from '../http-interceptors/http-error.interceptor'; import { batchResultCounts, selectionItem, selectionView } from './batch-actions.helpers'; import { RootBatchState, newBatchRequestId } from './batch-actions.models'; @@ -94,14 +92,6 @@ describe.each(fixtures)('$entity batch store/effects integration', (config) => { }, } : { total: items.length, nodes: items }; - const eligibility = (ids: string[], absent: string[] = []) => ({ - allDeletable: !absent.length, - items: ids.map((id) => - absent.includes(id) - ? { id, availability: 'not_found', canDelete: false, reasonCode: DeletionReasonCode.NOT_FOUND } - : { ...selectionItem(rows.find((row) => row.id === id)), availability: 'present', canDelete: true } - ), - }); const selectRows = (count = rows.length) => rows.slice(0, count).forEach((row) => store.dispatch(actions.toggleRow({ item: selectionItem(row) }))); const prepare = () => { @@ -129,7 +119,6 @@ describe.each(fixtures)('$entity batch store/effects integration', (config) => { subscriptions.add(events$.subscribe((action) => events.push(action))); response = new Subject(); data = { - checkDeletionEligibility: jest.fn((ids) => of(eligibility(ids))), batchDelete: jest.fn(() => response), [config.fetchMethod]: jest.fn(() => of(page(rows.filter((row) => !batch().removedIds.includes(row.id))))), }; @@ -172,7 +161,7 @@ describe.each(fixtures)('$entity batch store/effects integration', (config) => { translate as any, {} as any ); - // Include every batch effect so reintroducing a focus/refresh effect cannot escape the no-read assertions. + // Include every batch effect to exercise submission and completion together. const batchEffects = Object.keys(getEffectsMetadata(effects)).filter((field) => /batch/i.test(field)); for (const field of [...batchEffects, config.fetchEffect]) { subscriptions.add(effects[field].subscribe((action) => store.dispatch(action))); @@ -264,30 +253,11 @@ describe.each(fixtures)('$entity batch store/effects integration', (config) => { store.dispatch(actions.toggleRow({ item: selectionItem(rows[0]) })); expect(batch().confirmation).toBeNull(); expect(batch().selectedById[rows[0].id]).toBeUndefined(); - expect(data.checkDeletionEligibility).not.toHaveBeenCalled(); + store.dispatch(actions.batchDeleteRequested({ snapshot })); expect(data.batchDelete).not.toHaveBeenCalled(); }); - it('does not fetch eligibility on app/tab focus, opening confirmation, or a related mutation', fakeAsync(() => { - window.dispatchEvent(new Event('focus')); - tick(100); - expect(data.checkDeletionEligibility).not.toHaveBeenCalled(); - selectRows(); - window.dispatchEvent(new Event('focus')); - document.dispatchEvent(new Event('visibilitychange')); - store.dispatch(flagActions.actionDeleteFeatureFlagSuccess({ flag: { id: 'other' } as any })); - tick(100); - expect(data.checkDeletionEligibility).not.toHaveBeenCalled(); - const snapshot = prepare(); - store.dispatch(actions.batchDeleteRequested({ snapshot })); - window.dispatchEvent(new Event('focus')); - document.dispatchEvent(new Event('visibilitychange')); - tick(100); - expect(data.checkDeletionEligibility).not.toHaveBeenCalled(); - expect(data.batchDelete).toHaveBeenCalledTimes(1); - })); - it('uses updated page data to block a hidden restriction without an eligibility request', () => { selectRows(); const blocked = { @@ -303,7 +273,7 @@ describe.each(fixtures)('$entity batch store/effects integration', (config) => { expect(batch().confirmation).toBeNull(); expect(Object.keys(batch().selectedById)).toHaveLength(3); expect(selectionView(batch(), config.entity).canRequestConfirmation).toBe(false); - expect(data.checkDeletionEligibility).not.toHaveBeenCalled(); + expect(data.batchDelete).not.toHaveBeenCalled(); }); @@ -311,7 +281,7 @@ describe.each(fixtures)('$entity batch store/effects integration', (config) => { selectRows(); const snapshot = prepare(); expect(snapshot.items).toHaveLength(3); - expect(data.checkDeletionEligibility).not.toHaveBeenCalled(); + store.dispatch(actions.batchDeleteRequested({ snapshot })); response.next({ phase: 'rejected', @@ -323,7 +293,6 @@ describe.each(fixtures)('$entity batch store/effects integration', (config) => { expect(notifications.showWarning).toHaveBeenCalledTimes(1); expect(data[config.fetchMethod]).toHaveBeenLastCalledWith(expect.objectContaining({ skip: 0 }), true); expect(batch().confirmation).toBeNull(); - expect(data.checkDeletionEligibility).not.toHaveBeenCalled(); }); it('freezes the request, rejects duplicate submits, and preserves failures while refreshing the current query', () => { @@ -390,7 +359,7 @@ describe.each(fixtures)('$entity batch store/effects integration', (config) => { expect(state[config.key][config.loadingKey]).toBe(false); expect(Object.keys(batch().selectedById)).toHaveLength(3); expect(data[config.fetchMethod]).toHaveBeenCalledTimes(2); - expect(data.checkDeletionEligibility).not.toHaveBeenCalled(); + expect(notifications.showWarning).not.toHaveBeenCalled(); expect(notifications.showSuccess).not.toHaveBeenCalled(); } @@ -429,7 +398,7 @@ describe.each(fixtures)('$entity batch store/effects integration', (config) => { expect(batch().confirmation).toBe(snapshot); expect(snapshot.items.map(({ name }) => name)).toEqual(['a', 'b']); expect(batch().selectedById[rows[0].id].name).toBe('renamed'); - expect(data.checkDeletionEligibility).not.toHaveBeenCalled(); + store.dispatch(actions.batchDeleteRequested({ snapshot })); expect(data.batchDelete).toHaveBeenCalledWith([rows[0].id, rows[1].id]); })); @@ -458,61 +427,6 @@ describe.each(fixtures)('$entity batch store/effects integration', (config) => { expect(notifications.showSuccess).toHaveBeenCalledWith(`${count} ${noun}${count === 1 ? '' : 's'} deleted.`); }); - it('reconciles an explicit unknown server result with one bulk read and never retries deletion', () => { - selectRows(); - const snapshot = prepare(); - const reconcile = new Subject(); - data.checkDeletionEligibility.mockReturnValueOnce(reconcile); - store.dispatch(actions.batchDeleteRequested({ snapshot })); - response.next({ phase: 'executed', results: rows.map(({ id }) => ({ id, outcome: 'unknown' })) }); - expect(batch().operation.status).toBe('reconciling'); - expect(Object.keys(batch().selectedById)).toHaveLength(3); - reconcile.next( - eligibility( - rows.map((row) => row.id), - [rows[0].id] - ) - ); - expect(batchResultCounts(batch())).toMatchObject({ deleted: 0, absent: 1, uncertain: true }); - expect(Object.keys(batch().selectedById)).toHaveLength(2); - expect(notifications.showWarning).toHaveBeenCalledWith( - '1 item was already absent. Some outcomes could not be confirmed. Check the list before retrying.' - ); - expect(data.checkDeletionEligibility).toHaveBeenCalledTimes(1); - expect(data.checkDeletionEligibility).toHaveBeenCalledWith(rows.map((row) => row.id)); - expect(data.batchDelete).toHaveBeenCalledTimes(1); - expect(notifications.showWarning).toHaveBeenCalledTimes(1); - }); - - it.each([0, 500])('uses only the existing error notification when reconciliation fails with HTTP %i', (status) => { - selectRows(); - const snapshot = prepare(); - const popup = { create: jest.fn() }; - const interceptor = new HttpErrorInterceptor({ authLogout: jest.fn() } as any, popup as any, {} as any); - data.checkDeletionEligibility.mockReturnValue( - interceptor.intercept(new HttpRequest('POST', `/${config.entity}/deletion-eligibility`, {}), { - handle: () => throwError(() => new HttpErrorResponse({ status })), - }) - ); - const fetchCount = data[config.fetchMethod].mock.calls.length; - store.dispatch(actions.batchDeleteRequested({ snapshot })); - response.next({ - phase: 'executed', - results: rows.map(({ id }, index) => ({ id, outcome: index === 0 ? 'deleted' : 'unknown' })), - }); - expect(popup.create).toHaveBeenCalledTimes(1); - expect(popup.create.mock.calls[0][0]).toBe('Network call failed. See console for details.'); - expect(notifications.showWarning).not.toHaveBeenCalled(); - expect(notifications.showSuccess).not.toHaveBeenCalled(); - expect(data[config.fetchMethod]).toHaveBeenCalledTimes(fetchCount); - expect(data.checkDeletionEligibility).toHaveBeenCalledTimes(1); - expect(data.batchDelete).toHaveBeenCalledTimes(1); - expect(batch().operation.status).toBe('complete'); - expect(currentRows().map(({ id }) => id)).toEqual(rows.slice(1).map(({ id }) => id)); - expect(Object.keys(batch().selectedById)).toEqual(rows.slice(1).map(({ id }) => id)); - expect(selectionView(batch(), config.entity).canToggleHeader).toBe(true); - }); - it.each([0, 400, 403, 500, 504])( 'clears cancelled list loading and releases selection after HTTP %i without another request or snackbar', (status) => { @@ -534,7 +448,7 @@ describe.each(fixtures)('$entity batch store/effects integration', (config) => { expect(state[config.key][config.loadingKey]).toBe(false); expect(currentRows()).toEqual(beforeRows); expect(batch().loadedIds).toEqual(loadedIds); - expect(data.checkDeletionEligibility).not.toHaveBeenCalled(); + expect(data[config.fetchMethod]).toHaveBeenCalledTimes(fetchCount); expect(notifications.showWarning).not.toHaveBeenCalled(); expect(notifications.showSuccess).not.toHaveBeenCalled(); @@ -587,16 +501,45 @@ describe.each(fixtures)('$entity batch store/effects integration', (config) => { expect(notifications.showWarning).toHaveBeenCalledTimes(1); }); - it('warns about malformed delete and reconciliation responses without retrying deletion', () => { + it('retains an unknown item after refreshing the list and reports the confirmed results once', () => { + selectRows(); + const snapshot = prepare(); + const fetchCount = data[config.fetchMethod].mock.calls.length; + data[config.fetchMethod].mockReturnValueOnce(of(page([rows[2]]))); + store.dispatch(actions.batchDeleteRequested({ snapshot })); + response.next({ + phase: 'executed', + results: [ + { id: rows[0].id, outcome: 'deleted' }, + { id: rows[1].id, outcome: 'unknown' }, + { id: rows[2].id, outcome: 'not_attempted' }, + ], + }); + expect(batch().operation.status).toBe('complete'); + expect(Object.keys(batch().selectedById)).toEqual([rows[1].id, rows[2].id]); + expect(batchResultCounts(batch())).toMatchObject({ deleted: 1, absent: 0, uncertain: true, notAttempted: 1 }); + expect(selectionView(batch(), config.entity)).toMatchObject({ busy: false, notShownCount: 1 }); + const noun = { experiments: 'experiment', flags: 'feature flag', segments: 'segment' }[config.entity]; + expect(notifications.showWarning).toHaveBeenCalledWith( + `1 ${noun} deleted. 1 item was not attempted. Some outcomes could not be confirmed. Check the list before retrying.` + ); + expect(notifications.showWarning).toHaveBeenCalledTimes(1); + expect(notifications.showSuccess).not.toHaveBeenCalled(); + expect(data.batchDelete).toHaveBeenCalledTimes(1); + expect(data[config.fetchMethod]).toHaveBeenCalledTimes(fetchCount + 1); + }); + + it('retains uncertain selections and warns once about a malformed deletion response', () => { selectRows(); const snapshot = prepare(); - data.checkDeletionEligibility.mockReturnValueOnce(of({ items: [], allDeletable: false })); store.dispatch(actions.batchDeleteRequested({ snapshot })); response.next({ phase: 'executed', results: [] }); expect(batch().operation.result.results.every((result) => result.outcome === 'unknown')).toBe(true); expect(Object.keys(batch().selectedById)).toHaveLength(3); + expect(batch().operation.status).toBe('complete'); + expect(selectionView(batch(), config.entity).busy).toBe(false); expect(data.batchDelete).toHaveBeenCalledTimes(1); - expect(data.checkDeletionEligibility).toHaveBeenCalledTimes(1); + expect(notifications.showSuccess).not.toHaveBeenCalled(); expect(notifications.showWarning).toHaveBeenCalledTimes(1); }); diff --git a/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.models.ts b/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.models.ts index 647b8d8c7..34988aadf 100644 --- a/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.models.ts +++ b/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.models.ts @@ -1,6 +1,18 @@ -import { BatchDeleteResult, DeletionEligibilityItem, UserRole } from 'upgrade_types'; +import { + BatchDeleteResult, + EXPERIMENT_STATE, + FEATURE_FLAG_STATUS, + SEGMENT_STATUS, + SEGMENT_TYPE, + UserRole, +} from 'upgrade_types'; -export type RootSelectionItem = Pick; +export interface RootSelectionItem { + id: string; + name?: string; + stateOrStatus?: EXPERIMENT_STATE | FEATURE_FLAG_STATUS | SEGMENT_STATUS; + segmentType?: SEGMENT_TYPE; +} export interface BatchDeleteSnapshot { operationId: string; @@ -21,11 +33,9 @@ export interface RootBatchState { confirmation: BatchDeleteSnapshot | null; operation: { snapshot: BatchDeleteSnapshot; - status: 'submitting' | 'reconciling' | 'complete'; + status: 'submitting' | 'complete'; result?: BatchDeleteResult; transportStatus?: number; - reconciledAbsentIds: string[]; - reconciliationFailed?: boolean; } | null; } @@ -44,5 +54,4 @@ export const initialRootBatchState: RootBatchState = { let requestSequence = 0; export const newBatchRequestId = () => `batch-${++requestSequence}`; -export const isBatchBusy = (state: RootBatchState) => - state.operation?.status === 'submitting' || state.operation?.status === 'reconciling'; +export const isBatchBusy = (state: RootBatchState) => state.operation?.status === 'submitting'; diff --git a/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.reducer.ts b/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.reducer.ts index ab3f20ce7..e27357c86 100644 --- a/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.reducer.ts +++ b/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.reducer.ts @@ -137,14 +137,10 @@ export function reduceRootBatch( listRequestId: null, listLoading: false, confirmation: null, - operation: { snapshot, status: 'submitting', reconciledAbsentIds: [] }, + operation: { snapshot, status: 'submitting' }, }; } - if ( - matches(action, actions.batchDeleteCompleted) || - matches(action, actions.batchDeleteRequestFailed) || - matches(action, actions.reconciliationCompleted) - ) { + if (matches(action, actions.batchDeleteCompleted) || matches(action, actions.batchDeleteRequestFailed)) { if ( !state.operation || action.operationId !== state.operation.snapshot.operationId || @@ -158,7 +154,7 @@ export function reduceRootBatch( operation: { ...state.operation, result: action.result, - status: action.result.results.some((item) => item.outcome === 'unknown') ? 'reconciling' : 'complete', + status: 'complete', }, }; } @@ -187,19 +183,6 @@ export function reduceRootBatch( }, }; } - const absent = - action.result?.items.filter((item) => item.availability === 'not_found').map((item) => item.id) || []; - return { - ...removeConfirmed(state, absent), - operation: { - ...state.operation, - status: 'complete', - reconciledAbsentIds: absent.filter((id) => !confirmedRemovedIds(state.operation.result).includes(id)), - reconciliationFailed: !action.result, - // Reuse the HTTP-failure completion path: the interceptor already reported this error. - transportStatus: action.status, - }, - }; } return state; } diff --git a/packages/frontend/projects/upgrade/src/app/core/experiments/experiments.data.service.ts b/packages/frontend/projects/upgrade/src/app/core/experiments/experiments.data.service.ts index 2c7804ff3..47e729e7e 100644 --- a/packages/frontend/projects/upgrade/src/app/core/experiments/experiments.data.service.ts +++ b/packages/frontend/projects/upgrade/src/app/core/experiments/experiments.data.service.ts @@ -1,4 +1,4 @@ -import { BatchDeleteResult, DeletionEligibilityResult } from 'upgrade_types'; +import { BatchDeleteResult } from 'upgrade_types'; import { batchHttpContext } from '../batch-actions/batch-actions.http'; import { Injectable } from '@angular/core'; import { @@ -21,14 +21,6 @@ import { IImportFile, LIST_FILTER_MODE, ExperimentRewardsSummary } from 'upgrade @Injectable() export class ExperimentDataService { - checkDeletionEligibility(ids: string[]): Observable { - return this.http.post( - API_ENDPOINTS.experimentsDeletionEligibility, - { ids }, - { context: batchHttpContext() } - ); - } - batchDelete(ids: string[]): Observable { return this.http.post( API_ENDPOINTS.experimentsBatchDelete, 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 7fe5bda56..4be6f8963 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 @@ -1,9 +1,4 @@ -import { - batchDeleteEffect, - reconcileBatchEffect, - batchFinishedEffect, - trackedListRequest, -} from '../../batch-actions/batch-actions.effects'; +import { batchDeleteEffect, batchFinishedEffect, trackedListRequest } from '../../batch-actions/batch-actions.effects'; import { batchResultCounts, batchResultMessage } from '../../batch-actions/batch-actions.helpers'; import { selectRootBatch, selectExperimentState } from './experiments.selectors'; import { actionFetchListSegmentOptions } from '../../segments/store/segments.actions'; @@ -50,14 +45,6 @@ export class ExperimentEffects { this.experimentDataService ) ); - reconcileBatch$ = createEffect(() => - reconcileBatchEffect( - this.actions$, - this.store$.pipe(select(selectRootBatch)), - experimentAction.batchActions, - this.experimentDataService - ) - ); finishBatch$ = createEffect(() => batchFinishedEffect( this.actions$, diff --git a/packages/frontend/projects/upgrade/src/app/core/feature-flags/feature-flags.data.service.ts b/packages/frontend/projects/upgrade/src/app/core/feature-flags/feature-flags.data.service.ts index f9f07d2ff..267ccbd94 100644 --- a/packages/frontend/projects/upgrade/src/app/core/feature-flags/feature-flags.data.service.ts +++ b/packages/frontend/projects/upgrade/src/app/core/feature-flags/feature-flags.data.service.ts @@ -1,4 +1,4 @@ -import { BatchDeleteResult, DeletionEligibilityResult } from 'upgrade_types'; +import { BatchDeleteResult } from 'upgrade_types'; import { batchHttpContext } from '../batch-actions/batch-actions.http'; import { Injectable } from '@angular/core'; import { HttpClient, HttpContext, HttpParams } from '@angular/common/http'; @@ -27,14 +27,6 @@ import { IImportFile, LIST_FILTER_MODE } from 'upgrade_types'; @Injectable() export class FeatureFlagsDataService { - checkDeletionEligibility(ids: string[]): Observable { - return this.http.post( - API_ENDPOINTS.flagsDeletionEligibility, - { ids }, - { context: batchHttpContext() } - ); - } - batchDelete(ids: string[]): Observable { return this.http.post(API_ENDPOINTS.flagsBatchDelete, { ids }, { context: batchHttpContext() }); } 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 bcd562dbe..4d08fc804 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 @@ -1,9 +1,4 @@ -import { - batchDeleteEffect, - reconcileBatchEffect, - batchFinishedEffect, - trackedListRequest, -} from '../../batch-actions/batch-actions.effects'; +import { batchDeleteEffect, batchFinishedEffect, trackedListRequest } from '../../batch-actions/batch-actions.effects'; import { batchResultCounts, batchResultMessage } from '../../batch-actions/batch-actions.helpers'; import { selectRootBatch, selectFeatureFlagsState } from './feature-flags.selectors'; import { actionFetchListSegmentOptions } from '../../segments/store/segments.actions'; @@ -37,14 +32,6 @@ export class FeatureFlagsEffects { this.featureFlagsDataService ) ); - reconcileBatch$ = createEffect(() => - reconcileBatchEffect( - this.actions$, - this.store$.pipe(select(selectRootBatch)), - FeatureFlagsActions.batchActions, - this.featureFlagsDataService - ) - ); finishBatch$ = createEffect(() => batchFinishedEffect( this.actions$, diff --git a/packages/frontend/projects/upgrade/src/app/core/segments/segments.data.service.ts b/packages/frontend/projects/upgrade/src/app/core/segments/segments.data.service.ts index faa0c5c0e..9c3c269f3 100644 --- a/packages/frontend/projects/upgrade/src/app/core/segments/segments.data.service.ts +++ b/packages/frontend/projects/upgrade/src/app/core/segments/segments.data.service.ts @@ -1,4 +1,4 @@ -import { BatchDeleteResult, DeletionEligibilityResult } from 'upgrade_types'; +import { BatchDeleteResult } from 'upgrade_types'; import { batchHttpContext } from '../batch-actions/batch-actions.http'; import { Injectable } from '@angular/core'; import { @@ -19,14 +19,6 @@ import { API_ENDPOINTS } from '../api-endpoints.constants'; @Injectable() export class SegmentsDataService { - checkDeletionEligibility(ids: string[]): Observable { - return this.http.post( - API_ENDPOINTS.segmentsDeletionEligibility, - { ids }, - { context: batchHttpContext() } - ); - } - batchDelete(ids: string[]): Observable { return this.http.post( API_ENDPOINTS.segmentsBatchDelete, 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 aa5705de6..a92491938 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 @@ -1,9 +1,4 @@ -import { - batchDeleteEffect, - reconcileBatchEffect, - batchFinishedEffect, - trackedListRequest, -} from '../../batch-actions/batch-actions.effects'; +import { batchDeleteEffect, batchFinishedEffect, trackedListRequest } from '../../batch-actions/batch-actions.effects'; import { batchResultCounts, batchResultMessage } from '../../batch-actions/batch-actions.helpers'; import { selectRootBatch, selectSegmentsState } from './segments.selectors'; import { Injectable } from '@angular/core'; @@ -34,14 +29,6 @@ export class SegmentsEffects { this.segmentsDataService ) ); - reconcileBatch$ = createEffect(() => - reconcileBatchEffect( - this.actions$, - this.store$.pipe(select(selectRootBatch)), - SegmentsActions.batchActions, - this.segmentsDataService - ) - ); finishBatch$ = createEffect(() => batchFinishedEffect( this.actions$, diff --git a/packages/frontend/projects/upgrade/src/environments/environment-types.ts b/packages/frontend/projects/upgrade/src/environments/environment-types.ts index b895b17ee..f3baba850 100644 --- a/packages/frontend/projects/upgrade/src/environments/environment-types.ts +++ b/packages/frontend/projects/upgrade/src/environments/environment-types.ts @@ -3,11 +3,8 @@ import { InjectionToken } from '@angular/core'; export const ENV = new InjectionToken('env.token'); export interface APIEndpoints { - experimentsDeletionEligibility: string; experimentsBatchDelete: string; - flagsDeletionEligibility: string; flagsBatchDelete: string; - segmentsDeletionEligibility: string; segmentsBatchDelete: string; getAllExperiments: string; createNewExperiments: string; diff --git a/packages/types/src/BatchActions/index.ts b/packages/types/src/BatchActions/index.ts index d6e7a7c69..248a383c0 100644 --- a/packages/types/src/BatchActions/index.ts +++ b/packages/types/src/BatchActions/index.ts @@ -2,8 +2,6 @@ import { EXPERIMENT_STATE, EXPERIMENT_STATE_DISPLAY_NAME_OVERRIDES, FEATURE_FLAG_STATUS, - SEGMENT_STATUS, - SEGMENT_TYPE, UserRole, } from '../Experiment/enums'; @@ -30,21 +28,6 @@ export enum DeletionReasonCode { export type BatchDeleteEntity = 'experiments' | 'flags' | 'segments'; -export interface DeletionEligibilityItem { - id: string; - availability: 'present' | 'not_found' | 'unavailable'; - name?: string; - stateOrStatus?: EXPERIMENT_STATE | FEATURE_FLAG_STATUS | SEGMENT_STATUS; - segmentType?: SEGMENT_TYPE; - canDelete: boolean; - reasonCode?: DeletionReasonCode; -} - -export interface DeletionEligibilityResult { - items: DeletionEligibilityItem[]; - allDeletable: boolean; -} - export type BatchDeleteRequest = BatchEntityIdsRequest; export type BatchDeleteItemOutcome = @@ -73,7 +56,7 @@ export function hasBatchDeletePermission(role: UserRole, entity: BatchDeleteEnti ); } -export function getExperimentDeletionState(state: EXPERIMENT_STATE): EXPERIMENT_STATE { +function getExperimentDeletionState(state: EXPERIMENT_STATE): EXPERIMENT_STATE { return EXPERIMENT_STATE_DISPLAY_NAME_OVERRIDES[state] || state; } diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index 4eae613ba..79b2e47e6 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -98,12 +98,9 @@ export { BatchEntityIdsRequest, BatchDeleteEntity, hasBatchDeletePermission, - getExperimentDeletionState, getExperimentDeletionReason, getFlagDeletionReason, DeletionReasonCode, - DeletionEligibilityItem, - DeletionEligibilityResult, BatchDeleteRequest, BatchDeleteItemOutcome, BatchDeleteItemResult, From 66ddd7e537814f2275dcd25c3f87c80c2ac4a562 Mon Sep 17 00:00:00 2001 From: Zack Lee Date: Mon, 14 Sep 2026 11:02:43 -0400 Subject: [PATCH 15/61] fix: hide header selection checkboxes on empty tables --- .../projects/upgrade/src/styles/shared-batch-selection.scss | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/packages/frontend/projects/upgrade/src/styles/shared-batch-selection.scss b/packages/frontend/projects/upgrade/src/styles/shared-batch-selection.scss index 5bcae467c..2ccaddfde 100644 --- a/packages/frontend/projects/upgrade/src/styles/shared-batch-selection.scss +++ b/packages/frontend/projects/upgrade/src/styles/shared-batch-selection.scss @@ -43,6 +43,11 @@ white-space: nowrap; } +// Keep the header aligned; hidden selections must still be available to clear. +.no-data:not(.has-batch-selection) .mat-mdc-header-row .batch-checkbox { + visibility: hidden; +} + @media (hover: hover) and (pointer: fine) { .batch-checkbox { opacity: 0; From b3cfc957083848a448d85e569d8b8bdc2f85864d Mon Sep 17 00:00:00 2001 From: Zack Lee Date: Mon, 14 Sep 2026 11:14:13 -0400 Subject: [PATCH 16/61] test: remove redundant batch deletion checks and update rejection fixtures --- .../integration/SegmentDeletionGuard/index.ts | 20 ------------------- .../batch-actions/batch-actions.http.spec.ts | 19 +++++++----------- .../batch-actions.integration.spec.ts | 4 ++-- 3 files changed, 9 insertions(+), 34 deletions(-) diff --git a/packages/backend/test/integration/SegmentDeletionGuard/index.ts b/packages/backend/test/integration/SegmentDeletionGuard/index.ts index 44ebd69aa..06dbdde0d 100644 --- a/packages/backend/test/integration/SegmentDeletionGuard/index.ts +++ b/packages/backend/test/integration/SegmentDeletionGuard/index.ts @@ -271,26 +271,6 @@ export function registerSegmentDeletionGuardTests(connections: () => [DataSource 15000 ); - test.each(['archived owner state', 'nested parent'] as const)( - 'a target-only lock does not protect %s (negative control)', - async (change) => { - const fixture = await createChange(db, change); - const writer = await startWriter(writerDb); - try { - await db.transaction(async (manager) => { - await manager.query('SELECT id FROM segment WHERE id = $1 FOR UPDATE', [fixture.target.id]); - expect(await status(fixture.target.id)).toBe(SEGMENT_STATUS.UNUSED); - // Completes despite the target lock: related ancestors and experiment owners also need protection. - await fixture.apply(writer.manager); - await writer.commitTransaction(); - expect(await status(fixture.target.id)).toBe(SEGMENT_STATUS.USED); - }); - } finally { - await closeWriter(writer); - } - } - ); - test.each(['commit', 'rollback'] as const)( 'bounds lock waits without leaking the setting after %s', async (completion) => { diff --git a/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.http.spec.ts b/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.http.spec.ts index 867ca729a..be80fb864 100644 --- a/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.http.spec.ts +++ b/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.http.spec.ts @@ -30,18 +30,13 @@ describe('Batch HTTP contracts', () => { ['segments', SegmentsDataService], ] as const)('%s sends the complete selection in one deletion request', (entity, token) => { const service = TestBed.inject(token as typeof ExperimentDataService); - for (const count of [20, 100, 500]) { - const ids = Array.from( - { length: count }, - (_, index) => `00000000-0000-4000-8000-${String(index).padStart(12, '0')}` - ); - service.batchDelete(ids).subscribe(); - const deletion = http.expectOne(`/${entity}/batch-delete`); - expect(deletion.request.method).toBe('POST'); - expect(deletion.request.body).toEqual({ ids }); - expect(deletion.request.context.get(SKIP_NAVIGATION_CANCEL)).toBe(true); - deletion.flush({ phase: 'executed', results: [] }); - } + const ids = ['00000000-0000-4000-8000-000000000001', '00000000-0000-4000-8000-000000000002']; + service.batchDelete(ids).subscribe(); + const deletion = http.expectOne(`/${entity}/batch-delete`); + expect(deletion.request.method).toBe('POST'); + expect(deletion.request.body).toEqual({ ids }); + expect(deletion.request.context.get(SKIP_NAVIGATION_CANCEL)).toBe(true); + deletion.flush({ phase: 'executed', results: ids.map((id) => ({ id, outcome: 'deleted' })) }); }); it('continues observing a batch response through navigation', () => { diff --git a/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.integration.spec.ts b/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.integration.spec.ts index af4b61e1f..c596d114a 100644 --- a/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.integration.spec.ts +++ b/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.integration.spec.ts @@ -465,13 +465,13 @@ describe.each(fixtures)('$entity batch store/effects integration', (config) => { } ); - it('reports a preflight rejection without removing any selected or hidden row', () => { + it('retains every selection when the server reports all items as ineligible', () => { selectRows(); const snapshot = prepare(); store.dispatch(actions.batchDeleteRequested({ snapshot })); response.next({ phase: 'rejected', - results: rows.map(({ id }, index) => ({ id, outcome: index === 2 ? 'ineligible' : 'not_attempted' })), + results: rows.map(({ id }) => ({ id, outcome: 'ineligible' })), }); expect(currentRows()).toHaveLength(3); expect(Object.keys(batch().selectedById)).toHaveLength(3); From 9c711e9026b5cf91d1cf34455ba9bc7e99c825e2 Mon Sep 17 00:00:00 2001 From: Zack Lee Date: Mon, 14 Sep 2026 12:03:33 -0400 Subject: [PATCH 17/61] refactor: move batch segment lookup into its repository and clarify dialog focus behavior --- .../src/api/repositories/SegmentRepository.ts | 12 +++++++++++ .../batch/DeletionEligibilityService.ts | 15 +++++--------- .../batch/DeletionEligibilityService.test.ts | 20 ++++++++----------- .../shared/services/common-dialog.service.ts | 2 +- 4 files changed, 26 insertions(+), 23 deletions(-) diff --git a/packages/backend/src/api/repositories/SegmentRepository.ts b/packages/backend/src/api/repositories/SegmentRepository.ts index 2759e17ef..228938190 100644 --- a/packages/backend/src/api/repositories/SegmentRepository.ts +++ b/packages/backend/src/api/repositories/SegmentRepository.ts @@ -30,6 +30,18 @@ export class SegmentRepository extends Repository { }); } + public async findForDeletionEligibility(ids: string[]): Promise { + return this.createQueryBuilder('segment') + .leftJoin('segment.subSegments', 'child') + .select(['segment.id', 'segment.type', 'child.id']) + .where('segment.id IN (:...ids)', { ids }) + .getMany() + .catch((errorMsg: any) => { + const errorMsgString = repositoryError('segmentRepository', 'findForDeletionEligibility', { ids }, errorMsg); + throw errorMsgString; + }); + } + /** * Retrieves all segments of a specified type from the database. * diff --git a/packages/backend/src/api/services/batch/DeletionEligibilityService.ts b/packages/backend/src/api/services/batch/DeletionEligibilityService.ts index 927851352..4b58dfdf3 100644 --- a/packages/backend/src/api/services/batch/DeletionEligibilityService.ts +++ b/packages/backend/src/api/services/batch/DeletionEligibilityService.ts @@ -7,10 +7,10 @@ import { SEGMENT_STATUS, SEGMENT_TYPE, } from 'upgrade_types'; -import { InjectDataSource } from '../../../typeorm-typedi-extensions'; +import { InjectDataSource, InjectRepository } from '../../../typeorm-typedi-extensions'; import { Experiment } from '../../models/Experiment'; import { FeatureFlag } from '../../models/FeatureFlag'; -import { Segment } from '../../models/Segment'; +import { SegmentRepository } from '../../repositories/SegmentRepository'; import { SegmentService, SegmentWithStatus } from '../SegmentService'; export interface DeletionEligibilityItem { @@ -31,7 +31,8 @@ type EligibilitySummary = Omit; export class DeletionEligibilityService { constructor( @InjectDataSource() private dataSource: DataSource, - @Inject(() => SegmentService) private segmentService: SegmentService + @Inject(() => SegmentService) private segmentService: SegmentService, + @InjectRepository() private segmentRepository: SegmentRepository ) {} public async experiments(ids: string[]): Promise { @@ -57,13 +58,7 @@ export class DeletionEligibilityService { } public async segments(ids: string[]): Promise { - const rows = await this.dataSource - .getRepository(Segment) - .createQueryBuilder('segment') - .leftJoin('segment.subSegments', 'child') - .select(['segment.id', 'segment.type', 'child.id']) - .where('segment.id IN (:...ids)', { ids }) - .getMany(); + const rows = await this.segmentRepository.findForDeletionEligibility(ids); const publicRows = rows.filter((row) => row.type === SEGMENT_TYPE.PUBLIC); // One evaluation for the entire selection; no per-ID detail/member loads or mutation locks. const statuses = publicRows.length diff --git a/packages/backend/test/unit/services/batch/DeletionEligibilityService.test.ts b/packages/backend/test/unit/services/batch/DeletionEligibilityService.test.ts index bc424aef4..b2600c966 100644 --- a/packages/backend/test/unit/services/batch/DeletionEligibilityService.test.ts +++ b/packages/backend/test/unit/services/batch/DeletionEligibilityService.test.ts @@ -3,6 +3,7 @@ import { DataSource } from 'typeorm'; import { DeletionReasonCode, EXPERIMENT_STATE, FEATURE_FLAG_STATUS, SEGMENT_STATUS, SEGMENT_TYPE } from 'upgrade_types'; import { DeletionEligibilityService } from '../../../../src/api/services/batch/DeletionEligibilityService'; import { SegmentService } from '../../../../src/api/services/SegmentService'; +import { SegmentRepository } from '../../../../src/api/repositories/SegmentRepository'; const methods = ['experiments', 'flags', 'segments'] as const; @@ -10,7 +11,7 @@ describe('Internal batch deletion eligibility', () => { const id = randomUUID(); let service: DeletionEligibilityService; let find: jest.Mock; - let getMany: jest.Mock; + let findForDeletionEligibility: jest.Mock; let getRepository: jest.Mock; let getSegmentStatus: jest.Mock; beforeEach(() => { @@ -23,18 +24,13 @@ describe('Internal batch deletion eligibility', () => { subSegments: [], }; find = jest.fn().mockResolvedValue([row]); - getMany = jest.fn().mockResolvedValue([row]); - const builder = { - leftJoin: jest.fn().mockReturnThis(), - select: jest.fn().mockReturnThis(), - where: jest.fn().mockReturnThis(), - getMany, - }; - getRepository = jest.fn().mockReturnValue({ find, createQueryBuilder: () => builder }); + findForDeletionEligibility = jest.fn().mockResolvedValue([row]); + getRepository = jest.fn().mockReturnValue({ find }); getSegmentStatus = jest.fn().mockResolvedValue({ segmentsData: [{ ...row, status: SEGMENT_STATUS.UNUSED }] }); service = new DeletionEligibilityService( { getRepository } as unknown as DataSource, - { getSegmentStatus } as unknown as SegmentService + { getSegmentStatus } as unknown as SegmentService, + { findForDeletionEligibility } as unknown as SegmentRepository ); }); @@ -81,7 +77,7 @@ describe('Internal batch deletion eligibility', () => { test.each(methods)('%s propagates database failure instead of reporting absence', async (method) => { const failure = new Error('Database unavailable'); find.mockRejectedValue(failure); - getMany.mockRejectedValue(failure); + findForDeletionEligibility.mockRejectedValue(failure); await expect(service[method]([id])).rejects.toBe(failure); expect(getSegmentStatus).not.toHaveBeenCalled(); }); @@ -104,7 +100,7 @@ describe('Internal batch deletion eligibility', () => { }); test('does not run global status reads when no selected segment exists', async () => { - getMany.mockResolvedValue([]); + findForDeletionEligibility.mockResolvedValue([]); const result = await service.segments([id]); expect(result.items[0]).toMatchObject({ availability: 'not_found', canDelete: false }); expect(getSegmentStatus).not.toHaveBeenCalled(); diff --git a/packages/frontend/projects/upgrade/src/app/shared/services/common-dialog.service.ts b/packages/frontend/projects/upgrade/src/app/shared/services/common-dialog.service.ts index e234a5e4d..6e6e97b81 100644 --- a/packages/frontend/projects/upgrade/src/app/shared/services/common-dialog.service.ts +++ b/packages/frontend/projects/upgrade/src/app/shared/services/common-dialog.service.ts @@ -134,7 +134,7 @@ export class DialogService { isLoading$: facade.state$.pipe(map((state) => state.confirmation?.operationId !== snapshot.operationId)), }, }; - // Do not restore focus to the menu that launched the dialog, matching existing menu-based dialogs. + // Restoring focus here leaves the root menu trigger's focus circle visible after Cancel or Close. return this.openTextValidatedConfirmationModal(config, ModalSize.SMALL, false); } From 11df68c3e4cb659ca7fc8012e035cb64dd20f5eb Mon Sep 17 00:00:00 2001 From: Zack Lee Date: Mon, 14 Sep 2026 13:39:46 -0400 Subject: [PATCH 18/61] refactor: move deletion locking queries into the repository layer and correct flag deletion docs --- .../api/controllers/FeatureFlagController.ts | 2 +- .../api/repositories/DeletionRepository.ts | 128 ++++++++++++++++++ .../src/api/services/DeletionStateService.ts | 29 ++-- .../api/services/batch/BatchDeleteService.ts | 11 +- .../services/batch/SegmentDeletionGuard.ts | 82 ++--------- .../integration/SegmentDeletionGuard/index.ts | 8 +- .../controllers/ExperimentController.test.ts | 2 + .../controllers/FeatureFlagController.test.ts | 2 + .../controllers/SegmentController.test.ts | 2 + .../services/batch/BatchDeleteService.test.ts | 6 +- 10 files changed, 176 insertions(+), 96 deletions(-) create mode 100644 packages/backend/src/api/repositories/DeletionRepository.ts diff --git a/packages/backend/src/api/controllers/FeatureFlagController.ts b/packages/backend/src/api/controllers/FeatureFlagController.ts index 97a455b4a..e0329b451 100644 --- a/packages/backend/src/api/controllers/FeatureFlagController.ts +++ b/packages/backend/src/api/controllers/FeatureFlagController.ts @@ -469,7 +469,7 @@ export class FeatureFlagsController { * '200': * description: Delete Feature flag By Id * '400': - * description: Invalid UUID or feature flag is not disabled + * description: Invalid UUID or feature flag is neither Disabled nor Archived * '404': * description: Feature flag not found */ diff --git a/packages/backend/src/api/repositories/DeletionRepository.ts b/packages/backend/src/api/repositories/DeletionRepository.ts new file mode 100644 index 000000000..ca7343cec --- /dev/null +++ b/packages/backend/src/api/repositories/DeletionRepository.ts @@ -0,0 +1,128 @@ +import { EntityManager, Repository } from 'typeorm'; +import { EXPERIMENT_STATE, STANDARD_LIST_TYPE } from 'upgrade_types'; +import { EntityRepository } from '../../typeorm-typedi-extensions'; +import { Experiment } from '../models/Experiment'; +import { FeatureFlag } from '../models/FeatureFlag'; +import { Segment } from '../models/Segment'; +import repositoryError from './utils/repositoryError'; + +/** All reads and locks use the caller's deletion transaction, never the repository's default manager. */ +@EntityRepository() +export class DeletionRepository extends Repository { + public async getTransactionIsolation(manager: EntityManager): Promise { + const [{ transaction_isolation }] = await manager.query('SHOW transaction_isolation').catch((errorMsg: any) => { + throw repositoryError('DeletionRepository', 'getTransactionIsolation', {}, errorMsg); + }); + return transaction_isolation; + } + + public async setLockTimeout(manager: EntityManager): Promise { + // Cap each lock wait through cleanup/deletion; preserve any tighter caller/database limit. + // Transaction-local settings expire on commit/rollback. This is not a total request deadline. + await manager + .query( + `SELECT set_config('lock_timeout', CASE + WHEN current_setting('lock_timeout')::interval = interval '0' + OR current_setting('lock_timeout')::interval > interval '5 seconds' + THEN '5s' ELSE current_setting('lock_timeout') END, true)` + ) + .catch((errorMsg: any) => { + throw repositoryError('DeletionRepository', 'setLockTimeout', {}, errorMsg); + }); + } + + public async findDeletionState( + entity: 'experiments' | 'flags', + id: string, + manager: EntityManager + ): Promise | Pick> { + const query = + entity === 'experiments' + ? manager + .getRepository(Experiment) + .findOne({ where: { id }, select: { id: true, state: true }, lock: { mode: 'pessimistic_write' } }) + : manager + .getRepository(FeatureFlag) + .findOne({ where: { id }, select: { id: true, status: true }, lock: { mode: 'pessimistic_write' } }); + return query.catch((errorMsg: any) => { + throw repositoryError('DeletionRepository', 'findDeletionState', { entity, id }, errorMsg); + }); + } + + public async findSegmentForDeletion(id: string, manager: EntityManager): Promise> { + // FOR UPDATE conflicts with the KEY SHARE locks taken by new foreign-key references. + const [target] = await manager + .query('SELECT id, type FROM segment WHERE id = $1 FOR UPDATE', [id]) + .catch((errorMsg: any) => { + throw repositoryError('DeletionRepository', 'findSegmentForDeletion', { id }, errorMsg); + }); + return target; + } + + /** Call after locking the target with findSegmentForDeletion, in the same READ COMMITTED transaction. */ + public async lockReferencesAndCheckSegmentUsage(id: string, manager: EntityManager): Promise { + try { + const locked = new Set([id]); + let ancestors: string[]; + let unlocked: string[]; + do { + const rows: { id: string }[] = await manager.query( + `WITH RECURSIVE ancestors(id) AS ( + SELECT $1::uuid + UNION + SELECT link."parentSegmentId" + FROM segment_for_segment link + JOIN ancestors ON link."childSegmentId" = ancestors.id + ) + SELECT id FROM ancestors ORDER BY id`, + [id] + ); + ancestors = rows.map((row) => row.id); + unlocked = ancestors.filter((ancestor) => !locked.has(ancestor)); + if (unlocked.length) { + const acquired: { id: string }[] = await manager.query( + 'SELECT id FROM segment WHERE id = ANY($1::uuid[]) ORDER BY id FOR UPDATE', + [unlocked] + ); + acquired.forEach((row) => locked.add(row.id)); + } + // A writer may have committed while a lock was being acquired. Discover again with a fresh snapshot. + } while (unlocked.length); + + // Archived owners must also be locked: reactivation can make an otherwise unused segment Used. + // KEY SHARE would not stop a state-only UPDATE; SHARE does. + await manager.query( + `SELECT experiment.id FROM experiment WHERE experiment.id IN ( + SELECT "experimentId" FROM experiment_segment_inclusion WHERE "segmentId" = ANY($1::uuid[]) + UNION + SELECT "experimentId" FROM experiment_segment_exclusion WHERE "segmentId" = ANY($1::uuid[]) + ) ORDER BY experiment.id FOR SHARE`, + [ancestors] + ); + + const [{ used }] = await manager.query( + `SELECT + EXISTS ( + SELECT 1 FROM experiment_segment_inclusion ref JOIN experiment ON experiment.id = ref."experimentId" + WHERE ref."segmentId" = ANY($2::uuid[]) AND experiment.state <> $3 + ) OR EXISTS ( + SELECT 1 FROM experiment_segment_exclusion ref JOIN experiment ON experiment.id = ref."experimentId" + WHERE ref."segmentId" = ANY($2::uuid[]) AND experiment.state <> $3 + ) OR EXISTS ( + SELECT 1 FROM feature_flag_segment_inclusion WHERE "segmentId" = ANY($2::uuid[]) + ) OR EXISTS ( + SELECT 1 FROM feature_flag_segment_exclusion WHERE "segmentId" = ANY($2::uuid[]) + ) OR EXISTS ( + SELECT 1 FROM segment_for_segment child + JOIN segment list ON list.id = child."parentSegmentId" + WHERE child."childSegmentId" = $1 AND list."listType" = $4 + AND EXISTS (SELECT 1 FROM segment_for_segment parent WHERE parent."childSegmentId" = list.id) + ) AS used`, + [id, ancestors, EXPERIMENT_STATE.ARCHIVED, STANDARD_LIST_TYPE.SEGMENT] + ); + return used; + } catch (errorMsg) { + throw repositoryError('DeletionRepository', 'lockReferencesAndCheckSegmentUsage', { id }, errorMsg); + } + } +} diff --git a/packages/backend/src/api/services/DeletionStateService.ts b/packages/backend/src/api/services/DeletionStateService.ts index cd6c4c152..63669ac30 100644 --- a/packages/backend/src/api/services/DeletionStateService.ts +++ b/packages/backend/src/api/services/DeletionStateService.ts @@ -8,10 +8,11 @@ import { getExperimentDeletionReason, getFlagDeletionReason, } from 'upgrade_types'; -import { InjectDataSource } from '../../typeorm-typedi-extensions'; +import { InjectDataSource, InjectRepository } from '../../typeorm-typedi-extensions'; import { DeletionTransaction } from '../../types/DeletionTransaction'; import { Experiment } from '../models/Experiment'; import { FeatureFlag } from '../models/FeatureFlag'; +import { DeletionRepository } from '../repositories/DeletionRepository'; import { assertSegmentDeletionAllowed, SegmentDeletionBlockedError } from './batch/SegmentDeletionGuard'; export class DeletionBlockedError extends Error { @@ -24,11 +25,12 @@ export class DeletionBlockedError extends Error { export async function assertDeletionStateAllowed( entity: BatchDeleteEntity, id: string, - manager: EntityManager + manager: EntityManager, + repository: DeletionRepository ): Promise { if (entity === 'segments') { try { - await assertSegmentDeletionAllowed(id, manager); + await assertSegmentDeletionAllowed(id, manager, repository); } catch (error) { if (!(error instanceof SegmentDeletionBlockedError)) throw error; throw new DeletionBlockedError({ @@ -45,18 +47,8 @@ export async function assertDeletionStateAllowed( return; } - await manager.query(`SELECT set_config('lock_timeout', CASE - WHEN current_setting('lock_timeout')::interval = interval '0' - OR current_setting('lock_timeout')::interval > interval '5 seconds' - THEN '5s' ELSE current_setting('lock_timeout') END, true)`); - const row = - entity === 'experiments' - ? await manager - .getRepository(Experiment) - .findOne({ where: { id }, select: { id: true, state: true }, lock: { mode: 'pessimistic_write' } }) - : await manager - .getRepository(FeatureFlag) - .findOne({ where: { id }, select: { id: true, status: true }, lock: { mode: 'pessimistic_write' } }); + await repository.setLockTimeout(manager); + const row = await repository.findDeletionState(entity, id, manager); if (!row) throw new DeletionBlockedError({ id, outcome: 'not_found', reasonCode: DeletionReasonCode.NOT_FOUND }); const reason = entity === 'experiments' @@ -77,13 +69,16 @@ const rejectionMessages: Partial> = { /** Opt in at the three single-item API boundaries, not in services also used for internal list cleanup. */ @Service() export class DeletionStateService { - constructor(@InjectDataSource() private dataSource: DataSource) {} + constructor( + @InjectDataSource() private dataSource: DataSource, + @InjectRepository() private deletionRepository: DeletionRepository + ) {} transactionFor(entity: BatchDeleteEntity, id: string): DeletionTransaction { return async (work) => { try { return await this.dataSource.transaction('READ COMMITTED', async (manager) => { - await assertDeletionStateAllowed(entity, id, manager); + await assertDeletionStateAllowed(entity, id, manager, this.deletionRepository); return work(manager); }); } catch (error) { diff --git a/packages/backend/src/api/services/batch/BatchDeleteService.ts b/packages/backend/src/api/services/batch/BatchDeleteService.ts index efbfde60e..a4268caba 100644 --- a/packages/backend/src/api/services/batch/BatchDeleteService.ts +++ b/packages/backend/src/api/services/batch/BatchDeleteService.ts @@ -11,7 +11,7 @@ import { } from 'upgrade_types'; import { env } from '../../../env'; import { UpgradeLogger } from '../../../lib/logger/UpgradeLogger'; -import { InjectDataSource } from '../../../typeorm-typedi-extensions'; +import { InjectDataSource, InjectRepository } from '../../../typeorm-typedi-extensions'; import { DeletionTransaction } from '../../../types/DeletionTransaction'; import { UserDTO } from '../../DTO/UserDTO'; import { MoocletError } from '../../errors/MoocletError'; @@ -25,6 +25,7 @@ import { DeletionEligibilityResult, } from './DeletionEligibilityService'; import { assertDeletionStateAllowed, DeletionBlockedError } from '../DeletionStateService'; +import { DeletionRepository } from '../../repositories/DeletionRepository'; // Admission budget: never abandon an in-flight deletion or claim it has been cancelled. // Check before starting another item and again after acquiring its eligibility locks. @@ -38,7 +39,8 @@ export class BatchDeleteService { @Inject(() => ExperimentService) private experiments: ExperimentService, @Inject(() => FeatureFlagService) private flags: FeatureFlagService, @Inject(() => SegmentService) private segments: SegmentService, - @Inject(() => MoocletExperimentService) private mooclets: MoocletExperimentService + @Inject(() => MoocletExperimentService) private mooclets: MoocletExperimentService, + @InjectRepository() private deletionRepository: DeletionRepository ) {} public async delete( @@ -130,7 +132,8 @@ export class BatchDeleteService { await runner.connect(); await runner.startTransaction('READ COMMITTED'); // Segments use the existing beforeDelete hook inside this transaction. - if (entity !== 'segments') await assertDeletionStateAllowed(entity, id, runner.manager); + if (entity !== 'segments') + await assertDeletionStateAllowed(entity, id, runner.manager, this.deletionRepository); if (performance.now() >= deadline) { throw new DeletionBlockedError({ id, @@ -188,7 +191,7 @@ export class BatchDeleteService { id, logger, async (manager) => { - await assertDeletionStateAllowed(entity, id, manager); + await assertDeletionStateAllowed(entity, id, manager, this.deletionRepository); if (performance.now() >= deadline) { throw new DeletionBlockedError({ id, diff --git a/packages/backend/src/api/services/batch/SegmentDeletionGuard.ts b/packages/backend/src/api/services/batch/SegmentDeletionGuard.ts index 78e3c747f..8827c3d4c 100644 --- a/packages/backend/src/api/services/batch/SegmentDeletionGuard.ts +++ b/packages/backend/src/api/services/batch/SegmentDeletionGuard.ts @@ -1,5 +1,6 @@ import { EntityManager } from 'typeorm'; -import { EXPERIMENT_STATE, SEGMENT_TYPE, STANDARD_LIST_TYPE } from 'upgrade_types'; +import { SEGMENT_TYPE } from 'upgrade_types'; +import { DeletionRepository } from '../../repositories/DeletionRepository'; export class SegmentDeletionBlockedError extends Error { constructor(public readonly segmentId: string, public readonly reason: 'missing' | 'protected' | 'used') { @@ -8,36 +9,22 @@ export class SegmentDeletionBlockedError extends Error { } } -const ancestorQuery = ` - WITH RECURSIVE ancestors(id) AS ( - SELECT $1::uuid - UNION - SELECT link."parentSegmentId" - FROM segment_for_segment link - JOIN ancestors ON link."childSegmentId" = ancestors.id - ) - SELECT id FROM ancestors ORDER BY id -`; - /** Guard for ordinary public-segment deletion. Call inside the transaction that will delete this segment. */ -export async function assertSegmentDeletionAllowed(id: string, manager: EntityManager): Promise { +export async function assertSegmentDeletionAllowed( + id: string, + manager: EntityManager, + repository: DeletionRepository +): Promise { if (!manager.queryRunner?.isTransactionActive) { throw new Error('Segment deletion guard requires an active transaction'); } - const [{ transaction_isolation: isolation }] = await manager.query('SHOW transaction_isolation'); + const isolation = await repository.getTransactionIsolation(manager); if (isolation !== 'read committed') { throw new Error('Segment deletion guard requires READ COMMITTED isolation'); } - // Cap each lock wait through cleanup/deletion; preserve any tighter caller/database limit. - // Transaction-local settings expire on commit/rollback. This is not a total request deadline. - await manager.query(`SELECT set_config('lock_timeout', CASE - WHEN current_setting('lock_timeout')::interval = interval '0' - OR current_setting('lock_timeout')::interval > interval '5 seconds' - THEN '5s' ELSE current_setting('lock_timeout') END, true)`); - - // FOR UPDATE conflicts with the KEY SHARE locks taken by new foreign-key references. - const [target] = await manager.query('SELECT id, type FROM segment WHERE id = $1 FOR UPDATE', [id]); + await repository.setLockTimeout(manager); + const target = await repository.findSegmentForDeletion(id, manager); if (!target) { throw new SegmentDeletionBlockedError(id, 'missing'); } @@ -45,54 +32,7 @@ export async function assertSegmentDeletionAllowed(id: string, manager: EntityMa throw new SegmentDeletionBlockedError(id, 'protected'); } - const locked = new Set([id]); - let ancestors: string[]; - let unlocked: string[]; - do { - const rows: { id: string }[] = await manager.query(ancestorQuery, [id]); - ancestors = rows.map((row) => row.id); - unlocked = ancestors.filter((ancestor) => !locked.has(ancestor)); - if (unlocked.length) { - const acquired: { id: string }[] = await manager.query( - 'SELECT id FROM segment WHERE id = ANY($1::uuid[]) ORDER BY id FOR UPDATE', - [unlocked] - ); - acquired.forEach((row) => locked.add(row.id)); - } - // A writer may have committed while a lock was being acquired. Discover again with a fresh snapshot. - } while (unlocked.length); - - // Archived owners must also be locked: reactivation can make an otherwise unused segment Used. - // KEY SHARE would not stop a state-only UPDATE; SHARE does. - await manager.query( - `SELECT experiment.id FROM experiment WHERE experiment.id IN ( - SELECT "experimentId" FROM experiment_segment_inclusion WHERE "segmentId" = ANY($1::uuid[]) - UNION - SELECT "experimentId" FROM experiment_segment_exclusion WHERE "segmentId" = ANY($1::uuid[]) - ) ORDER BY experiment.id FOR SHARE`, - [ancestors] - ); - - const [{ used }] = await manager.query( - `SELECT - EXISTS ( - SELECT 1 FROM experiment_segment_inclusion ref JOIN experiment ON experiment.id = ref."experimentId" - WHERE ref."segmentId" = ANY($2::uuid[]) AND experiment.state <> $3 - ) OR EXISTS ( - SELECT 1 FROM experiment_segment_exclusion ref JOIN experiment ON experiment.id = ref."experimentId" - WHERE ref."segmentId" = ANY($2::uuid[]) AND experiment.state <> $3 - ) OR EXISTS ( - SELECT 1 FROM feature_flag_segment_inclusion WHERE "segmentId" = ANY($2::uuid[]) - ) OR EXISTS ( - SELECT 1 FROM feature_flag_segment_exclusion WHERE "segmentId" = ANY($2::uuid[]) - ) OR EXISTS ( - SELECT 1 FROM segment_for_segment child - JOIN segment list ON list.id = child."parentSegmentId" - WHERE child."childSegmentId" = $1 AND list."listType" = $4 - AND EXISTS (SELECT 1 FROM segment_for_segment parent WHERE parent."childSegmentId" = list.id) - ) AS used`, - [id, ancestors, EXPERIMENT_STATE.ARCHIVED, STANDARD_LIST_TYPE.SEGMENT] - ); + const used = await repository.lockReferencesAndCheckSegmentUsage(id, manager); if (used) { throw new SegmentDeletionBlockedError(id, 'used'); } diff --git a/packages/backend/test/integration/SegmentDeletionGuard/index.ts b/packages/backend/test/integration/SegmentDeletionGuard/index.ts index 06dbdde0d..ebdca1def 100644 --- a/packages/backend/test/integration/SegmentDeletionGuard/index.ts +++ b/packages/backend/test/integration/SegmentDeletionGuard/index.ts @@ -18,7 +18,9 @@ import { FeatureFlagSegmentExclusion } from '../../../src/api/models/FeatureFlag import { IndividualForSegment } from '../../../src/api/models/IndividualForSegment'; import { Segment } from '../../../src/api/models/Segment'; import { SegmentService, SegmentWithStatus } from '../../../src/api/services/SegmentService'; -import { assertSegmentDeletionAllowed } from '../../../src/api/services/batch/SegmentDeletionGuard'; +import { assertSegmentDeletionAllowed as assertAllowed } from '../../../src/api/services/batch/SegmentDeletionGuard'; +import { DeletionRepository } from '../../../src/api/repositories/DeletionRepository'; +import { Container as repositoryContainer } from '../../../src/typeorm-typedi-extensions'; import { UpgradeLogger } from '../../../src/lib/logger/UpgradeLogger'; const changes = [ @@ -166,9 +168,13 @@ export function registerSegmentDeletionGuardTests(connections: () => [DataSource let db: DataSource; let writerDb: DataSource; let service: SegmentService; + let deletionRepository: DeletionRepository; + const assertSegmentDeletionAllowed = (id: string, manager: EntityManager) => + assertAllowed(id, manager, deletionRepository); beforeEach(() => { [db, writerDb] = connections(); service = Container.get(SegmentService); + deletionRepository = repositoryContainer.getCustomRepository(DeletionRepository); }); const status = async (id: string) => { diff --git a/packages/backend/test/unit/controllers/ExperimentController.test.ts b/packages/backend/test/unit/controllers/ExperimentController.test.ts index 96138b37e..be5c061fe 100644 --- a/packages/backend/test/unit/controllers/ExperimentController.test.ts +++ b/packages/backend/test/unit/controllers/ExperimentController.test.ts @@ -1,4 +1,5 @@ import { BatchDeleteService } from '../../../src/api/services/batch/BatchDeleteService'; +import { DeletionStateService } from '../../../src/api/services/DeletionStateService'; import app from '../../utils/expressApp'; import request from 'supertest'; import { configureLogger } from '../../utils/logger'; @@ -36,6 +37,7 @@ describe('Experiment Controller Testing', () => { routingUseContainer(Container); classValidatorUseContainer(Container); Container.set(BatchDeleteService, {} as BatchDeleteService); + Container.set(DeletionStateService, { transactionFor: jest.fn() } as unknown as DeletionStateService); // set mock container Container.set(ExperimentService, new ExperimentServiceMock()); diff --git a/packages/backend/test/unit/controllers/FeatureFlagController.test.ts b/packages/backend/test/unit/controllers/FeatureFlagController.test.ts index 956ff305f..cc5b00300 100644 --- a/packages/backend/test/unit/controllers/FeatureFlagController.test.ts +++ b/packages/backend/test/unit/controllers/FeatureFlagController.test.ts @@ -1,4 +1,5 @@ import { BatchDeleteService } from '../../../src/api/services/batch/BatchDeleteService'; +import { DeletionStateService } from '../../../src/api/services/DeletionStateService'; import app from '../../utils/expressApp'; import request from 'supertest'; import { configureLogger } from '../../utils/logger'; @@ -20,6 +21,7 @@ describe('Feature Flag Controller Testing', () => { routingUseContainer(Container); classValidatorUseContainer(Container); Container.set(BatchDeleteService, {} as BatchDeleteService); + Container.set(DeletionStateService, { transactionFor: jest.fn() } as unknown as DeletionStateService); Container.set(FeatureFlagService, new FeatureFlagServiceMock()); Container.set(ExperimentUserService, new ExperimentUserServiceMock()); diff --git a/packages/backend/test/unit/controllers/SegmentController.test.ts b/packages/backend/test/unit/controllers/SegmentController.test.ts index 6b9ee6dbc..7394b4436 100644 --- a/packages/backend/test/unit/controllers/SegmentController.test.ts +++ b/packages/backend/test/unit/controllers/SegmentController.test.ts @@ -1,4 +1,5 @@ import { BatchDeleteService } from '../../../src/api/services/batch/BatchDeleteService'; +import { DeletionStateService } from '../../../src/api/services/DeletionStateService'; import app from '../../utils/expressApp'; import request from 'supertest'; import { configureLogger } from '../../utils/logger'; @@ -16,6 +17,7 @@ describe('Segment Controller Testing', () => { routingUseContainer(Container); classValidatorUseContainer(Container); Container.set(BatchDeleteService, {} as BatchDeleteService); + Container.set(DeletionStateService, { transactionFor: jest.fn() } as unknown as DeletionStateService); // set mock container Container.set(SegmentService, new SegmentServiceMock()); diff --git a/packages/backend/test/unit/services/batch/BatchDeleteService.test.ts b/packages/backend/test/unit/services/batch/BatchDeleteService.test.ts index 1a1dd591a..93335aaac 100644 --- a/packages/backend/test/unit/services/batch/BatchDeleteService.test.ts +++ b/packages/backend/test/unit/services/batch/BatchDeleteService.test.ts @@ -1,6 +1,6 @@ import { randomUUID } from 'crypto'; import { performance } from 'perf_hooks'; -import { DataSource, QueryRunner } from 'typeorm'; +import { DataSource, EntityManager, QueryRunner } from 'typeorm'; import { BatchDeleteEntity, DeletionReasonCode, UserRole } from 'upgrade_types'; import { BatchDeleteService } from '../../../../src/api/services/batch/BatchDeleteService'; import { @@ -15,6 +15,7 @@ import { MoocletError } from '../../../../src/api/errors/MoocletError'; import { UpgradeLogger } from '../../../../src/lib/logger/UpgradeLogger'; import { env } from '../../../../src/env'; import * as segmentGuard from '../../../../src/api/services/batch/SegmentDeletionGuard'; +import { DeletionRepository } from '../../../../src/api/repositories/DeletionRepository'; jest.mock('perf_hooks', () => ({ performance: { now: jest.fn(() => 0) } })); @@ -102,7 +103,8 @@ describe('BatchDeleteService transaction outcomes', () => { experiments as unknown as ExperimentService, flags as unknown as FeatureFlagService, segments as unknown as SegmentService, - mooclets as unknown as MoocletExperimentService + mooclets as unknown as MoocletExperimentService, + new DeletionRepository(DeletionRepository, {} as EntityManager) ); }); afterEach(() => { From 1e28434f6d10ba6ec3c284f808c241d3f2bcc29e Mon Sep 17 00:00:00 2001 From: Zack Lee Date: Mon, 14 Sep 2026 14:06:24 -0400 Subject: [PATCH 19/61] refactor: move experiment and flag deletion eligibility queries into repositories --- .../api/repositories/ExperimentRepository.ts | 12 ++++++- .../api/repositories/FeatureFlagRepository.ts | 12 ++++++- .../batch/DeletionEligibilityService.ts | 20 +++++------- .../batch/DeletionEligibilityService.test.ts | 31 ++++++++++--------- 4 files changed, 46 insertions(+), 29 deletions(-) diff --git a/packages/backend/src/api/repositories/ExperimentRepository.ts b/packages/backend/src/api/repositories/ExperimentRepository.ts index f1cfd1dcc..3c106968b 100644 --- a/packages/backend/src/api/repositories/ExperimentRepository.ts +++ b/packages/backend/src/api/repositories/ExperimentRepository.ts @@ -1,5 +1,5 @@ import { EXPERIMENT_STATE, SERVER_ERROR } from 'upgrade_types'; -import { Repository, EntityManager, Brackets, SelectQueryBuilder } from 'typeorm'; +import { Repository, EntityManager, Brackets, SelectQueryBuilder, In } from 'typeorm'; import { EntityRepository } from '../../typeorm-typedi-extensions'; import { Experiment } from '../models/Experiment'; import repositoryError from './utils/repositoryError'; @@ -121,6 +121,16 @@ export class ExperimentRepository extends Repository { }); } + public async findForDeletionEligibility(ids: string[]): Promise>> { + return this.find({ + where: { id: In(ids) }, + select: { id: true, state: true }, + }).catch((errorMsg: any) => { + const errorMsgString = repositoryError('ExperimentRepository', 'findForDeletionEligibility', { ids }, errorMsg); + throw errorMsgString; + }); + } + public async findContextByExperimentId(id: string): Promise { const experiment = await this.createQueryBuilder('experiment') .select(['experiment.id', 'experiment.context']) diff --git a/packages/backend/src/api/repositories/FeatureFlagRepository.ts b/packages/backend/src/api/repositories/FeatureFlagRepository.ts index c6a1ea1f2..2f0f0f3b2 100644 --- a/packages/backend/src/api/repositories/FeatureFlagRepository.ts +++ b/packages/backend/src/api/repositories/FeatureFlagRepository.ts @@ -1,4 +1,4 @@ -import { Repository, EntityManager, SelectQueryBuilder } from 'typeorm'; +import { Repository, EntityManager, SelectQueryBuilder, In } from 'typeorm'; import { EntityRepository } from '../../typeorm-typedi-extensions'; import { FeatureFlag } from '../models/FeatureFlag'; import repositoryError from './utils/repositoryError'; @@ -150,6 +150,16 @@ export class FeatureFlagRepository extends Repository { return result; } + public async findForDeletionEligibility(ids: string[]): Promise>> { + return this.find({ + where: { id: In(ids) }, + select: { id: true, status: true }, + }).catch((errorMsg: any) => { + const errorMsgString = repositoryError('FeatureFlagRepository', 'findForDeletionEligibility', { ids }, errorMsg); + throw errorMsgString; + }); + } + public async validateUniqueKey(flagDTO: FeatureFlagValidation) { const queryBuilder = this.createQueryBuilder('feature_flag') .where('feature_flag.key = :key', { key: flagDTO.key }) diff --git a/packages/backend/src/api/services/batch/DeletionEligibilityService.ts b/packages/backend/src/api/services/batch/DeletionEligibilityService.ts index 4b58dfdf3..b3a16272e 100644 --- a/packages/backend/src/api/services/batch/DeletionEligibilityService.ts +++ b/packages/backend/src/api/services/batch/DeletionEligibilityService.ts @@ -1,5 +1,4 @@ import { Inject, Service } from 'typedi'; -import { DataSource, In } from 'typeorm'; import { DeletionReasonCode, getExperimentDeletionReason, @@ -7,9 +6,9 @@ import { SEGMENT_STATUS, SEGMENT_TYPE, } from 'upgrade_types'; -import { InjectDataSource, InjectRepository } from '../../../typeorm-typedi-extensions'; -import { Experiment } from '../../models/Experiment'; -import { FeatureFlag } from '../../models/FeatureFlag'; +import { InjectRepository } from '../../../typeorm-typedi-extensions'; +import { ExperimentRepository } from '../../repositories/ExperimentRepository'; +import { FeatureFlagRepository } from '../../repositories/FeatureFlagRepository'; import { SegmentRepository } from '../../repositories/SegmentRepository'; import { SegmentService, SegmentWithStatus } from '../SegmentService'; @@ -30,16 +29,14 @@ type EligibilitySummary = Omit; @Service() export class DeletionEligibilityService { constructor( - @InjectDataSource() private dataSource: DataSource, + @InjectRepository() private experimentRepository: ExperimentRepository, + @InjectRepository() private featureFlagRepository: FeatureFlagRepository, @Inject(() => SegmentService) private segmentService: SegmentService, @InjectRepository() private segmentRepository: SegmentRepository ) {} public async experiments(ids: string[]): Promise { - const rows = await this.dataSource.getRepository(Experiment).find({ - where: { id: In(ids) }, - select: { id: true, state: true }, - }); + const rows = await this.experimentRepository.findForDeletionEligibility(ids); return this.result(ids, rows, (row) => ({ availability: 'present', reasonCode: getExperimentDeletionReason(row.state), @@ -47,10 +44,7 @@ export class DeletionEligibilityService { } public async flags(ids: string[]): Promise { - const rows = await this.dataSource.getRepository(FeatureFlag).find({ - where: { id: In(ids) }, - select: { id: true, status: true }, - }); + const rows = await this.featureFlagRepository.findForDeletionEligibility(ids); return this.result(ids, rows, (row) => ({ availability: 'present', reasonCode: getFlagDeletionReason(row.status), diff --git a/packages/backend/test/unit/services/batch/DeletionEligibilityService.test.ts b/packages/backend/test/unit/services/batch/DeletionEligibilityService.test.ts index b2600c966..914a10aa7 100644 --- a/packages/backend/test/unit/services/batch/DeletionEligibilityService.test.ts +++ b/packages/backend/test/unit/services/batch/DeletionEligibilityService.test.ts @@ -1,8 +1,9 @@ import { randomUUID } from 'crypto'; -import { DataSource } from 'typeorm'; import { DeletionReasonCode, EXPERIMENT_STATE, FEATURE_FLAG_STATUS, SEGMENT_STATUS, SEGMENT_TYPE } from 'upgrade_types'; import { DeletionEligibilityService } from '../../../../src/api/services/batch/DeletionEligibilityService'; import { SegmentService } from '../../../../src/api/services/SegmentService'; +import { ExperimentRepository } from '../../../../src/api/repositories/ExperimentRepository'; +import { FeatureFlagRepository } from '../../../../src/api/repositories/FeatureFlagRepository'; import { SegmentRepository } from '../../../../src/api/repositories/SegmentRepository'; const methods = ['experiments', 'flags', 'segments'] as const; @@ -10,9 +11,9 @@ const methods = ['experiments', 'flags', 'segments'] as const; describe('Internal batch deletion eligibility', () => { const id = randomUUID(); let service: DeletionEligibilityService; - let find: jest.Mock; - let findForDeletionEligibility: jest.Mock; - let getRepository: jest.Mock; + let findExperiments: jest.Mock; + let findFlags: jest.Mock; + let findSegments: jest.Mock; let getSegmentStatus: jest.Mock; beforeEach(() => { const row = { @@ -23,14 +24,15 @@ describe('Internal batch deletion eligibility', () => { type: SEGMENT_TYPE.PUBLIC, subSegments: [], }; - find = jest.fn().mockResolvedValue([row]); - findForDeletionEligibility = jest.fn().mockResolvedValue([row]); - getRepository = jest.fn().mockReturnValue({ find }); + findExperiments = jest.fn().mockResolvedValue([row]); + findFlags = jest.fn().mockResolvedValue([row]); + findSegments = jest.fn().mockResolvedValue([row]); getSegmentStatus = jest.fn().mockResolvedValue({ segmentsData: [{ ...row, status: SEGMENT_STATUS.UNUSED }] }); service = new DeletionEligibilityService( - { getRepository } as unknown as DataSource, + { findForDeletionEligibility: findExperiments } as unknown as ExperimentRepository, + { findForDeletionEligibility: findFlags } as unknown as FeatureFlagRepository, { getSegmentStatus } as unknown as SegmentService, - { findForDeletionEligibility } as unknown as SegmentRepository + { findForDeletionEligibility: findSegments } as unknown as SegmentRepository ); }); @@ -46,7 +48,7 @@ describe('Internal batch deletion eligibility', () => { EXPERIMENT_STATE.SCHEDULED, ]; const rows = states.map((state) => ({ id: randomUUID(), state })); - find.mockResolvedValue([...rows].reverse()); + findExperiments.mockResolvedValue([...rows].reverse()); const ids = [...rows.map((row) => row.id.toUpperCase()), randomUUID()]; const result = await service.experiments(ids); expect(result.items.map((item) => item.id)).toEqual(ids); @@ -68,7 +70,7 @@ describe('Internal batch deletion eligibility', () => { const rows = [FEATURE_FLAG_STATUS.DISABLED, FEATURE_FLAG_STATUS.ARCHIVED, FEATURE_FLAG_STATUS.ENABLED].map( (status) => ({ id: randomUUID(), status }) ); - find.mockResolvedValue(rows); + findFlags.mockResolvedValue(rows); const result = await service.flags(rows.map((row) => row.id)); expect(result.items.map((item) => item.canDelete)).toEqual([true, true, false]); expect(result.items[2].reasonCode).toBe(DeletionReasonCode.FEATURE_FLAG_ENABLED); @@ -76,8 +78,9 @@ describe('Internal batch deletion eligibility', () => { test.each(methods)('%s propagates database failure instead of reporting absence', async (method) => { const failure = new Error('Database unavailable'); - find.mockRejectedValue(failure); - findForDeletionEligibility.mockRejectedValue(failure); + findExperiments.mockRejectedValue(failure); + findFlags.mockRejectedValue(failure); + findSegments.mockRejectedValue(failure); await expect(service[method]([id])).rejects.toBe(failure); expect(getSegmentStatus).not.toHaveBeenCalled(); }); @@ -100,7 +103,7 @@ describe('Internal batch deletion eligibility', () => { }); test('does not run global status reads when no selected segment exists', async () => { - findForDeletionEligibility.mockResolvedValue([]); + findSegments.mockResolvedValue([]); const result = await service.segments([id]); expect(result.items[0]).toMatchObject({ availability: 'not_found', canDelete: false }); expect(getSegmentStatus).not.toHaveBeenCalled(); From f10d90327e77a8530b9aa794672fc88f37a7d077 Mon Sep 17 00:00:00 2001 From: Zack Lee Date: Mon, 14 Sep 2026 15:10:42 -0400 Subject: [PATCH 20/61] refactor: remove unused batch selection exports and redundant confirmation revision tracking --- .../app/core/batch-actions/batch-actions.actions.ts | 1 - .../app/core/batch-actions/batch-actions.models.ts | 3 --- .../app/core/batch-actions/batch-actions.reducer.ts | 11 +++-------- .../experiments/store/experiments.reducer.spec.ts | 2 +- .../core/experiments/store/experiments.selectors.ts | 2 -- .../feature-flags/store/feature-flags.selectors.ts | 2 -- .../src/app/core/segments/store/segments.selectors.ts | 2 -- .../batch-confirmation.spec.ts | 1 - 8 files changed, 4 insertions(+), 20 deletions(-) diff --git a/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.actions.ts b/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.actions.ts index 456d606e9..4b973f125 100644 --- a/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.actions.ts +++ b/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.actions.ts @@ -26,4 +26,3 @@ export function createBatchActions(source: string) { } export type RootBatchActions = ReturnType; -export type RootBatchAction = ReturnType; diff --git a/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.models.ts b/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.models.ts index 34988aadf..1694ed928 100644 --- a/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.models.ts +++ b/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.models.ts @@ -16,14 +16,12 @@ export interface RootSelectionItem { export interface BatchDeleteSnapshot { operationId: string; - revision: number; items: RootSelectionItem[]; notShownCount: number; } export interface RootBatchState { selectedById: Record; - revision: number; userEmail: string | null; role: UserRole | null; loadedIds: string[]; @@ -41,7 +39,6 @@ export interface RootBatchState { export const initialRootBatchState: RootBatchState = { selectedById: {}, - revision: 0, userEmail: null, role: null, loadedIds: [], diff --git a/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.reducer.ts b/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.reducer.ts index e27357c86..b28e22e16 100644 --- a/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.reducer.ts +++ b/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.reducer.ts @@ -12,8 +12,8 @@ function matches( return action.type === creator.type; } -export function invalidateSelection(state: RootBatchState): RootBatchState { - return { ...state, revision: state.revision + 1, confirmation: null }; +function invalidateSelection(state: RootBatchState): RootBatchState { + return { ...state, confirmation: null }; } function removeConfirmed(state: RootBatchState, ids: string[]): RootBatchState { @@ -35,17 +35,14 @@ export function receiveListRows( const removed = new Set(state.removedIds); const rows = items.filter((item) => !removed.has(item.id)); const selectedById = { ...state.selectedById }; - let changed = false; rows.forEach((item) => { if (selectedById[item.id]) { - changed = changed || JSON.stringify(selectedById[item.id]) !== JSON.stringify(item); selectedById[item.id] = item; } }); return { - ...(changed ? invalidateSelection(state) : state), // A background page refresh must not replace the snapshot already shown in the dialog. - confirmation: state.confirmation, + ...state, selectedById, loadedIds: [...new Set([...(fromStarting ? [] : state.loadedIds), ...rows.map((item) => item.id)])], listLoading: false, @@ -114,7 +111,6 @@ export function reduceRootBatch( ...state, confirmation: { operationId: action.operationId, - revision: state.revision, items: selection.items.map((item) => ({ ...item })), notShownCount: selection.notShownCount, }, @@ -128,7 +124,6 @@ export function reduceRootBatch( isBatchBusy(state) || !snapshot || snapshot.operationId !== action.snapshot.operationId || - snapshot.revision !== action.snapshot.revision || !hasBatchDeletePermission(state.role, entity) ) return state; diff --git a/packages/frontend/projects/upgrade/src/app/core/experiments/store/experiments.reducer.spec.ts b/packages/frontend/projects/upgrade/src/app/core/experiments/store/experiments.reducer.spec.ts index 876983349..ce827fa57 100644 --- a/packages/frontend/projects/upgrade/src/app/core/experiments/store/experiments.reducer.spec.ts +++ b/packages/frontend/projects/upgrade/src/app/core/experiments/store/experiments.reducer.spec.ts @@ -502,7 +502,7 @@ describe('ExperimentsReducer', () => { expect(newState).not.toBe(previousState); expect(newState).toEqual({ ...previousState, - rootBatch: { ...previousState.rootBatch, removedIds: ['1'], revision: 1 }, + rootBatch: { ...previousState.rootBatch, removedIds: ['1'] }, stats: {}, experiments: [], isLoadingExperimentDelete: false, diff --git a/packages/frontend/projects/upgrade/src/app/core/experiments/store/experiments.selectors.ts b/packages/frontend/projects/upgrade/src/app/core/experiments/store/experiments.selectors.ts index 1742c5969..409826237 100644 --- a/packages/frontend/projects/upgrade/src/app/core/experiments/store/experiments.selectors.ts +++ b/packages/frontend/projects/upgrade/src/app/core/experiments/store/experiments.selectors.ts @@ -1,4 +1,3 @@ -import { selectionView } from '../../batch-actions/batch-actions.helpers'; import { createSelector, createFeatureSelector } from '@ngrx/store'; import { EXPERIMENT_SEARCH_KEY, @@ -590,4 +589,3 @@ export const selectExperimentMenuItems = createSelector( ); export const selectRootBatch = createSelector(selectExperimentState, (state) => state.rootBatch); -export const selectRootSelection = createSelector(selectRootBatch, (state) => selectionView(state, 'experiments')); diff --git a/packages/frontend/projects/upgrade/src/app/core/feature-flags/store/feature-flags.selectors.ts b/packages/frontend/projects/upgrade/src/app/core/feature-flags/store/feature-flags.selectors.ts index 0ccbabe43..bd9a247ad 100644 --- a/packages/frontend/projects/upgrade/src/app/core/feature-flags/store/feature-flags.selectors.ts +++ b/packages/frontend/projects/upgrade/src/app/core/feature-flags/store/feature-flags.selectors.ts @@ -1,4 +1,3 @@ -import { selectionView } from '../../batch-actions/batch-actions.helpers'; import { createSelector, createFeatureSelector } from '@ngrx/store'; import { DetailsPageError } from '@shared-component-lib/common-page-error/common-page-error.model'; import { FeatureFlag, FeatureFlagState, ParticipantListTableRow } from './feature-flags.model'; @@ -250,4 +249,3 @@ export const selectWarningKeysForAllFlags = createSelector(selectFeatureFlagsSta }); export const selectRootBatch = createSelector(selectFeatureFlagsState, (state) => state.rootBatch); -export const selectRootSelection = createSelector(selectRootBatch, (state) => selectionView(state, 'flags')); diff --git a/packages/frontend/projects/upgrade/src/app/core/segments/store/segments.selectors.ts b/packages/frontend/projects/upgrade/src/app/core/segments/store/segments.selectors.ts index 58363831f..0b393aa83 100644 --- a/packages/frontend/projects/upgrade/src/app/core/segments/store/segments.selectors.ts +++ b/packages/frontend/projects/upgrade/src/app/core/segments/store/segments.selectors.ts @@ -1,4 +1,3 @@ -import { selectionView } from '../../batch-actions/batch-actions.helpers'; import { createSelector, createFeatureSelector } from '@ngrx/store'; import { DetailsPageError } from '@shared-component-lib/common-page-error/common-page-error.model'; import { @@ -331,4 +330,3 @@ function processParentSegments(segmentData: Segment[], segmentId: string, result } export const selectRootBatch = createSelector(selectSegmentsState, (state) => state.rootBatch); -export const selectRootSelection = createSelector(selectRootBatch, (state) => selectionView(state, 'segments')); diff --git a/packages/frontend/projects/upgrade/src/app/shared-standalone-component-lib/components/common-simple-text-validated-confirmation-modal/batch-confirmation.spec.ts b/packages/frontend/projects/upgrade/src/app/shared-standalone-component-lib/components/common-simple-text-validated-confirmation-modal/batch-confirmation.spec.ts index d4da03db8..1d850f145 100644 --- a/packages/frontend/projects/upgrade/src/app/shared-standalone-component-lib/components/common-simple-text-validated-confirmation-modal/batch-confirmation.spec.ts +++ b/packages/frontend/projects/upgrade/src/app/shared-standalone-component-lib/components/common-simple-text-validated-confirmation-modal/batch-confirmation.spec.ts @@ -23,7 +23,6 @@ describe('Batch deletion using the existing text confirmation dialog', () => { function open(entity: BatchDeleteEntity = 'experiments', count = 1, hidden = 0) { const snapshot = { operationId: 'operation', - revision: 1, notShownCount: hidden, items: Array.from({ length: count }, (_, index) => ({ id: String(index), name: `Item ${index}` })), }; From 4e0dd3fd6aa5af971414e0b9af169c1b6372c742 Mon Sep 17 00:00:00 2001 From: Zack Lee Date: Mon, 14 Sep 2026 15:52:30 -0400 Subject: [PATCH 21/61] fix: show existing table loading indicators during batch deletion --- .../dashboard/batch-delete-ui.spec.ts | 38 +++++++++++++++++-- ...ent-root-section-card-table.component.html | 2 +- ...lag-root-section-card-table.component.html | 2 +- ...ent-root-section-card-table.component.html | 2 +- 4 files changed, 38 insertions(+), 6 deletions(-) diff --git a/packages/frontend/projects/upgrade/src/app/features/dashboard/batch-delete-ui.spec.ts b/packages/frontend/projects/upgrade/src/app/features/dashboard/batch-delete-ui.spec.ts index 24c797662..09fc0d06f 100644 --- a/packages/frontend/projects/upgrade/src/app/features/dashboard/batch-delete-ui.spec.ts +++ b/packages/frontend/projects/upgrade/src/app/features/dashboard/batch-delete-ui.spec.ts @@ -62,6 +62,7 @@ describe.each(cases)('$entity root batch UI', (config) => { let closed: Subject; let overlay: OverlayContainer; let permissions$: BehaviorSubject; + let listLoading$: BehaviorSubject; const actions = config.actions.batchActions; const rows = ['Alpha', 'Beta'].map((name, index) => ({ id: `11111111-2222-4333-8444-${String(index + 1).padStart(12, '0')}`, @@ -149,14 +150,15 @@ describe.each(cases)('$entity root batch UI', (config) => { (s) => s[config.key][config.key] ); const rows$ = store.select((s) => s[config.key][config.key]); + listLoading$ = new BehaviorSubject(false); service = { batch: batchFacade, experiments$: rows$, featureFlags$: rows$, selectAllSegments$: rows$, - isLoadingExperiment$: of(false), - isLoadingFeatureFlags$: of(false), - isLoadingSegments$: of(false), + isLoadingExperiment$: listLoading$, + isLoadingFeatureFlags$: listLoading$, + isLoadingSegments$: listLoading$, haveInitialExperimentsLoaded: () => of(true), isInitialFeatureFlagsLoading$: of(true), isInitialSegmentsLoading: () => of(true), @@ -406,15 +408,45 @@ describe.each(cases)('$entity root batch UI', (config) => { expect(trigger.disabled).toBe(config.entity !== 'segments'); }); + it.each(['list', 'deletion'])('keeps the existing progress bar until both requests finish (%s first)', (first) => { + const progressBar = () => fixture.nativeElement.querySelector('mat-progress-bar'); + selectFirst(); + expect(progressBar()).toBeNull(); + store.dispatch(actions.prepareConfirmation({ operationId: 'pending-delete' })); + store.dispatch(actions.batchDeleteRequested({ snapshot: batch().confirmation })); + fixture.detectChanges(); + expect(progressBar()).not.toBeNull(); + + listLoading$.next(true); + const finishDeletion = () => + store.dispatch( + actions.batchDeleteCompleted({ + operationId: 'pending-delete', + result: { phase: 'executed', results: [{ id: rows[0].id, outcome: 'deleted' }] }, + }) + ); + if (first === 'list') listLoading$.next(false); + else finishDeletion(); + fixture.detectChanges(); + expect(progressBar()).not.toBeNull(); + + if (first === 'list') finishDeletion(); + else listLoading$.next(false); + fixture.detectChanges(); + expect(progressBar()).toBeNull(); + }); + it('keeps checkboxes usable without a banner or reload button after request and refresh failures', () => { selectFirst(); store.dispatch(actions.prepareConfirmation({ operationId: 'offline-delete' })); store.dispatch(actions.batchDeleteRequested({ snapshot: batch().confirmation })); fixture.detectChanges(); expect(checkboxes()[1].disabled).toBe(true); + expect(fixture.nativeElement.querySelector('mat-progress-bar')).not.toBeNull(); store.dispatch(actions.batchDeleteRequestFailed({ operationId: 'offline-delete', status: 0 })); fixture.detectChanges(); expect(checkboxes().every((input) => !input.disabled)).toBe(true); + expect(fixture.nativeElement.querySelector('mat-progress-bar')).toBeNull(); checkboxes()[1].click(); fixture.detectChanges(); expect(Object.keys(batch().selectedById)).toHaveLength(0); diff --git a/packages/frontend/projects/upgrade/src/app/features/dashboard/experiments/pages/experiment-root-page/experiment-root-page-content/experiment-root-section-card/experiment-root-section-card-table/experiment-root-section-card-table.component.html b/packages/frontend/projects/upgrade/src/app/features/dashboard/experiments/pages/experiment-root-page/experiment-root-page-content/experiment-root-section-card/experiment-root-section-card-table/experiment-root-section-card-table.component.html index 757709aac..208f8e7d9 100644 --- a/packages/frontend/projects/upgrade/src/app/features/dashboard/experiments/pages/experiment-root-page/experiment-root-page-content/experiment-root-section-card/experiment-root-section-card-table/experiment-root-section-card-table.component.html +++ b/packages/frontend/projects/upgrade/src/app/features/dashboard/experiments/pages/experiment-root-page/experiment-root-page-content/experiment-root-section-card/experiment-root-section-card-table/experiment-root-section-card-table.component.html @@ -1,6 +1,6 @@ @let selection = (batch.selection$ | async); @let batchState = (batch.state$ | async);
- @if (isLoading$ | async) { + @if ((isLoading$ | async) || selection?.busy) { } - @if (isLoading$ | async) { + @if ((isLoading$ | async) || selection?.busy) { } @let featureFlags = (featureFlags$ | async);
- @if (isLoading$ | async) { + @if ((isLoading$ | async) || selection?.busy) { } @let segments = (segments$ | async);
Date: Mon, 14 Sep 2026 18:31:15 -0400 Subject: [PATCH 22/61] fix: retain server-reported deletion restrictions for selected items --- .../batch-actions/batch-actions.helpers.ts | 1 + .../batch-actions.integration.spec.ts | 75 +++++++++++++------ .../batch-actions/batch-actions.models.ts | 2 + .../batch-actions/batch-actions.reducer.ts | 11 +++ 4 files changed, 67 insertions(+), 22 deletions(-) diff --git a/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.helpers.ts b/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.helpers.ts index 72108bc90..399fbfa34 100644 --- a/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.helpers.ts +++ b/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.helpers.ts @@ -28,6 +28,7 @@ export function localDeletionReason( role: RootBatchState['role'] ): DeletionReasonCode | undefined { if (!hasBatchDeletePermission(role, entity)) return DeletionReasonCode.MISSING_PERMISSION; + if (item.reasonCode) return item.reasonCode; if (entity === 'experiments') return getExperimentDeletionReason(item.stateOrStatus as EXPERIMENT_STATE); if (entity === 'flags') return getFlagDeletionReason(item.stateOrStatus as FEATURE_FLAG_STATUS); if (item.segmentType !== SEGMENT_TYPE.PUBLIC) return DeletionReasonCode.PROTECTED_SEGMENT_TYPE; diff --git a/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.integration.spec.ts b/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.integration.spec.ts index c596d114a..89980249e 100644 --- a/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.integration.spec.ts +++ b/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.integration.spec.ts @@ -365,28 +365,59 @@ describe.each(fixtures)('$entity batch store/effects integration', (config) => { } ); - it('removes two successful deletions and retains only the selection whose state changed', () => { - selectRows(); - const snapshot = prepare(); - const blocked = { - ...rows[1], - state: config.entity === 'experiments' ? EXPERIMENT_STATE.DRAFT : undefined, - status: config.entity === 'segments' ? SEGMENT_STATUS.USED : FEATURE_FLAG_STATUS.ENABLED, - }; - data[config.fetchMethod].mockReturnValueOnce(of(page([blocked]))); - store.dispatch(actions.batchDeleteRequested({ snapshot })); - response.next({ - phase: 'executed', - results: rows.map(({ id }, index) => ({ id, outcome: index === 1 ? 'ineligible' : 'deleted' })), - }); - expect(Object.keys(batch().selectedById)).toEqual([rows[1].id]); - expect(currentRows().map((row) => row.id)).toEqual([rows[1].id]); - expect(selectionView(batch(), config.entity).canRequestConfirmation).toBe(false); - const noun = { experiments: 'experiments', flags: 'feature flags', segments: 'segments' }[config.entity]; - expect(notifications.showWarning).toHaveBeenCalledTimes(1); - expect(notifications.showWarning).toHaveBeenCalledWith(`2 ${noun} deleted. 1 item could not be deleted.`); - expect(data.batchDelete).toHaveBeenCalledTimes(1); - }); + it.each(['visible', 'hidden', 'refresh failure'])( + 'retains the server restriction until fresh row data arrives (%s)', + (mode) => { + selectRows(); + if (mode === 'hidden') { + data[config.fetchMethod].mockReturnValueOnce(of(page([rows[0], rows[2]]))); + store.dispatch(config.fetch({ fromStarting: true })); + } + const snapshot = prepare(); + expect(snapshot.notShownCount).toBe(mode === 'hidden' ? 1 : 0); + const blocked = { + ...rows[1], + state: config.entity === 'experiments' ? EXPERIMENT_STATE.DRAFT : undefined, + status: config.entity === 'segments' ? SEGMENT_STATUS.USED : FEATURE_FLAG_STATUS.ENABLED, + }; + const reasonCode = { + experiments: DeletionReasonCode.EXPERIMENT_STATE_UNSUPPORTED, + flags: DeletionReasonCode.FEATURE_FLAG_ENABLED, + segments: DeletionReasonCode.SEGMENT_IN_USE, + }[config.entity]; + data[config.fetchMethod].mockReturnValueOnce( + mode === 'refresh failure' + ? throwError(() => new Error('refresh unavailable')) + : of(page(mode === 'hidden' ? [] : [blocked])) + ); + store.dispatch(actions.batchDeleteRequested({ snapshot })); + response.next({ + phase: 'executed', + results: rows.map(({ id }, index) => ({ + id, + outcome: index === 1 ? 'ineligible' : 'deleted', + reasonCode: index === 1 ? reasonCode : undefined, + })), + }); + response.complete(); + expect(Object.keys(batch().selectedById)).toEqual([rows[1].id]); + expect(currentRows().map((row) => row.id)).toEqual(mode === 'hidden' ? [] : [rows[1].id]); + expect(selectionView(batch(), config.entity)).toMatchObject({ canRequestConfirmation: false, reasonCode }); + store.dispatch(actions.prepareConfirmation({ operationId: 'retry-before-refresh' })); + expect(batch().confirmation).toBeNull(); + const noun = { experiments: 'experiments', flags: 'feature flags', segments: 'segments' }[config.entity]; + expect(notifications.showWarning).toHaveBeenCalledTimes(1); + expect(notifications.showWarning).toHaveBeenCalledWith(`2 ${noun} deleted. 1 item could not be deleted.`); + expect(data.batchDelete).toHaveBeenCalledTimes(1); + + data[config.fetchMethod].mockReturnValueOnce(of(page([rows[1]]))); + store.dispatch(config.fetch({ fromStarting: true })); + expect(selectionView(batch(), config.entity)).toMatchObject({ + canRequestConfirmation: true, + reasonCode: undefined, + }); + } + ); it('keeps the displayed confirmation snapshot when updated rows arrive or focus returns', fakeAsync(() => { selectRows(2); diff --git a/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.models.ts b/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.models.ts index 1694ed928..62682c3ca 100644 --- a/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.models.ts +++ b/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.models.ts @@ -1,5 +1,6 @@ import { BatchDeleteResult, + DeletionReasonCode, EXPERIMENT_STATE, FEATURE_FLAG_STATUS, SEGMENT_STATUS, @@ -12,6 +13,7 @@ export interface RootSelectionItem { name?: string; stateOrStatus?: EXPERIMENT_STATE | FEATURE_FLAG_STATUS | SEGMENT_STATUS; segmentType?: SEGMENT_TYPE; + reasonCode?: DeletionReasonCode; } export interface BatchDeleteSnapshot { diff --git a/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.reducer.ts b/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.reducer.ts index b28e22e16..5f445aceb 100644 --- a/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.reducer.ts +++ b/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.reducer.ts @@ -144,8 +144,19 @@ export function reduceRootBatch( return state; if (matches(action, actions.batchDeleteCompleted)) { const next = removeConfirmed(state, confirmedRemovedIds(action.result)); + const selectedById = { ...next.selectedById }; + // Retain a known restriction even when the current-query refresh omits the item or fails. + for (const { id, outcome, reasonCode } of action.result.results) { + if (outcome === 'ineligible' && selectedById[id]) { + selectedById[id] = { + ...selectedById[id], + reasonCode: reasonCode || DeletionReasonCode.ELIGIBILITY_UNAVAILABLE, + }; + } + } return { ...invalidateSelection(next), + selectedById, operation: { ...state.operation, result: action.result, From 027b4bde3d5284947689a3782a9c7fc81c26f631 Mon Sep 17 00:00:00 2001 From: Zack Lee Date: Mon, 14 Sep 2026 20:50:56 -0400 Subject: [PATCH 23/61] refactor: simplify batch deletion feedback --- .../batch-actions/batch-actions.helpers.spec.ts | 13 ++++++------- .../app/core/batch-actions/batch-actions.helpers.ts | 1 - .../batch-actions/batch-actions.integration.spec.ts | 11 ++++------- .../app/core/batch-actions/batch-actions.models.ts | 1 - .../app/core/batch-actions/batch-actions.reducer.ts | 1 - .../app/features/dashboard/batch-delete-ui.spec.ts | 5 ++++- .../batch-confirmation.spec.ts | 7 +++---- .../directives/root-batch-actions.directive.ts | 6 +----- .../app/shared/services/common-dialog.service.ts | 5 ----- .../projects/upgrade/src/assets/i18n/en.json | 9 +++------ 10 files changed, 21 insertions(+), 38 deletions(-) diff --git a/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.helpers.spec.ts b/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.helpers.spec.ts index 7877f222f..a3d95cbe8 100644 --- a/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.helpers.spec.ts +++ b/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.helpers.spec.ts @@ -28,18 +28,17 @@ describe('Root selection rules', () => { }); it.each([ - [[], [], false, false, 0], - [['a'], ['a', 'b'], false, true, 0], - [['a', 'b'], ['a', 'b'], true, false, 0], - [['a', 'b', 'hidden'], ['a', 'b'], true, false, 1], - [['a', 'hidden'], [], false, true, 2], + [[], [], false, false], + [['a'], ['a', 'b'], false, true], + [['a', 'b'], ['a', 'b'], true, false], + [['a', 'b', 'hidden'], ['a', 'b'], true, false], + [['a', 'hidden'], [], false, true], ])( 'derives the header from selected=%j and loaded=%j', - (selected: string[], loaded: string[], checked: boolean, indeterminate: boolean, notShownCount: number) => { + (selected: string[], loaded: string[], checked: boolean, indeterminate: boolean) => { expect(selectionView(state(selected, loaded), 'segments')).toMatchObject({ checked, indeterminate, - notShownCount, }); } ); diff --git a/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.helpers.ts b/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.helpers.ts index 399fbfa34..35f98cf8c 100644 --- a/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.helpers.ts +++ b/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.helpers.ts @@ -49,7 +49,6 @@ export function selectionView(state: RootBatchState, entity: BatchDeleteEntity) selectedCount: items.length, checked, indeterminate: items.length > 0 && !checked, - notShownCount: items.filter((item) => !loaded.has(item.id)).length, canToggleHeader: !isBatchBusy(state) && (items.length > 0 || loaded.size > 0), canRequestConfirmation: items.length > 0 && !reasons.length && !isBatchBusy(state), reasonCode: reasons[0], diff --git a/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.integration.spec.ts b/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.integration.spec.ts index 89980249e..bba181c04 100644 --- a/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.integration.spec.ts +++ b/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.integration.spec.ts @@ -209,7 +209,6 @@ describe.each(fixtures)('$entity batch store/effects integration', (config) => { selectedCount: 2, checked: true, indeterminate: false, - notShownCount: 1, }); store.dispatch(actions.toggleHeader({ items: [selectionItem(rows[0])] })); expect(Object.keys(batch().selectedById)).toEqual([]); @@ -374,7 +373,7 @@ describe.each(fixtures)('$entity batch store/effects integration', (config) => { store.dispatch(config.fetch({ fromStarting: true })); } const snapshot = prepare(); - expect(snapshot.notShownCount).toBe(mode === 'hidden' ? 1 : 0); + expect(snapshot.items.map(({ id }) => id)).toEqual(rows.map(({ id }) => id)); const blocked = { ...rows[1], state: config.entity === 'experiments' ? EXPERIMENT_STATE.DRAFT : undefined, @@ -520,9 +519,7 @@ describe.each(fixtures)('$entity batch store/effects integration', (config) => { results: [{ id: rows[0].id, outcome: 'deleted', reasonCode: 'post_delete_failed' }], }); const noun = { experiments: 'experiment', flags: 'feature flag', segments: 'segment' }[config.entity]; - expect(notifications.showWarning).toHaveBeenCalledWith( - `1 ${noun} deleted. Related updates could not be completed.` - ); + expect(notifications.showWarning).toHaveBeenCalledWith(`1 ${noun} deleted. An error occurred after deletion.`); expect(batch().selectedById[rows[0].id]).toBeUndefined(); expect(batch().operation.result.results[0].outcome).toBe('deleted'); expect(batch().listLoading).toBe(false); @@ -549,10 +546,10 @@ describe.each(fixtures)('$entity batch store/effects integration', (config) => { expect(batch().operation.status).toBe('complete'); expect(Object.keys(batch().selectedById)).toEqual([rows[1].id, rows[2].id]); expect(batchResultCounts(batch())).toMatchObject({ deleted: 1, absent: 0, uncertain: true, notAttempted: 1 }); - expect(selectionView(batch(), config.entity)).toMatchObject({ busy: false, notShownCount: 1 }); + expect(selectionView(batch(), config.entity).busy).toBe(false); const noun = { experiments: 'experiment', flags: 'feature flag', segments: 'segment' }[config.entity]; expect(notifications.showWarning).toHaveBeenCalledWith( - `1 ${noun} deleted. 1 item was not attempted. Some outcomes could not be confirmed. Check the list before retrying.` + `1 ${noun} deleted. 1 item was not attempted. Deletion could not be confirmed for some items.` ); expect(notifications.showWarning).toHaveBeenCalledTimes(1); expect(notifications.showSuccess).not.toHaveBeenCalled(); diff --git a/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.models.ts b/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.models.ts index 62682c3ca..991961aaa 100644 --- a/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.models.ts +++ b/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.models.ts @@ -19,7 +19,6 @@ export interface RootSelectionItem { export interface BatchDeleteSnapshot { operationId: string; items: RootSelectionItem[]; - notShownCount: number; } export interface RootBatchState { diff --git a/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.reducer.ts b/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.reducer.ts index 5f445aceb..12e5066aa 100644 --- a/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.reducer.ts +++ b/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.reducer.ts @@ -112,7 +112,6 @@ export function reduceRootBatch( confirmation: { operationId: action.operationId, items: selection.items.map((item) => ({ ...item })), - notShownCount: selection.notShownCount, }, }; } diff --git a/packages/frontend/projects/upgrade/src/app/features/dashboard/batch-delete-ui.spec.ts b/packages/frontend/projects/upgrade/src/app/features/dashboard/batch-delete-ui.spec.ts index 09fc0d06f..64df28233 100644 --- a/packages/frontend/projects/upgrade/src/app/features/dashboard/batch-delete-ui.spec.ts +++ b/packages/frontend/projects/upgrade/src/app/features/dashboard/batch-delete-ui.spec.ts @@ -4,6 +4,7 @@ import { NoopAnimationsModule } from '@angular/platform-browser/animations'; import { provideRouter } from '@angular/router'; import { OverlayContainer } from '@angular/cdk/overlay'; import { MatDialog } from '@angular/material/dialog'; +import { MatTooltip } from '@angular/material/tooltip'; import { Store, StoreModule } from '@ngrx/store'; import { TranslateModule, TranslateService } from '@ngx-translate/core'; import { BehaviorSubject, Subject, Subscription, of } from 'rxjs'; @@ -278,7 +279,6 @@ describe.each(cases)('$entity root batch UI', (config) => { const [entity, snapshot] = dialogs.openBatchDeleteModal.mock.calls[0]; expect(entity).toBe(config.entity); expect(snapshot.items.map((item) => item.id)).toEqual(rows.map((row) => row.id)); - expect(snapshot.notShownCount).toBe(1); closed.next(undefined); fixture.detectChanges(); expect(Object.keys(batch().selectedById)).toHaveLength(2); @@ -416,6 +416,9 @@ describe.each(cases)('$entity root batch UI', (config) => { store.dispatch(actions.batchDeleteRequested({ snapshot: batch().confirmation })); fixture.detectChanges(); expect(progressBar()).not.toBeNull(); + const trigger = fixture.debugElement.query(By.css('.section-card-menu-trigger')); + expect(trigger.nativeElement.disabled).toBe(true); + expect(trigger.parent.injector.get(MatTooltip).message).toBe(''); listLoading$.next(true); const finishDeletion = () => diff --git a/packages/frontend/projects/upgrade/src/app/shared-standalone-component-lib/components/common-simple-text-validated-confirmation-modal/batch-confirmation.spec.ts b/packages/frontend/projects/upgrade/src/app/shared-standalone-component-lib/components/common-simple-text-validated-confirmation-modal/batch-confirmation.spec.ts index 1d850f145..d1cbeb82d 100644 --- a/packages/frontend/projects/upgrade/src/app/shared-standalone-component-lib/components/common-simple-text-validated-confirmation-modal/batch-confirmation.spec.ts +++ b/packages/frontend/projects/upgrade/src/app/shared-standalone-component-lib/components/common-simple-text-validated-confirmation-modal/batch-confirmation.spec.ts @@ -20,10 +20,9 @@ describe('Batch deletion using the existing text confirmation dialog', () => { ref.componentRef.changeDetectorRef.detectChanges(); tick(); } - function open(entity: BatchDeleteEntity = 'experiments', count = 1, hidden = 0) { + function open(entity: BatchDeleteEntity = 'experiments', count = 1) { const snapshot = { operationId: 'operation', - notShownCount: hidden, items: Array.from({ length: count }, (_, index) => ({ id: String(index), name: `Item ${index}` })), }; state$ = new BehaviorSubject({ ...initialRootBatchState, confirmation: snapshot }); @@ -66,11 +65,11 @@ describe('Batch deletion using the existing text confirmation dialog', () => { ] as const)( 'uses common confirmation for %s with %i items', fakeAsync((entity, count, title, phrase) => { - open(entity, count, 1); + open(entity, count); expect(ref.componentInstance).toBeInstanceOf(CommonSimpleTextValidatedConfirmationModalComponent); expect(container.querySelector('h4').textContent).toBe(title); expect(container.textContent).toContain(`Are you sure you want to delete ${phrase}?`); - expect(container.textContent).toContain('1 selected item is not shown'); + expect(container.querySelectorAll('.validation-modal-content > p')).toHaveLength(1); expect((container.querySelector('.cdk-overlay-pane') as HTMLElement).style.width).toBe('480px'); expect(container.querySelector('input').autocomplete).toBe('off'); expect(primary().classList.contains('mat-warn')).toBe(true); diff --git a/packages/frontend/projects/upgrade/src/app/shared/directives/root-batch-actions.directive.ts b/packages/frontend/projects/upgrade/src/app/shared/directives/root-batch-actions.directive.ts index e21f0e669..85470c9de 100644 --- a/packages/frontend/projects/upgrade/src/app/shared/directives/root-batch-actions.directive.ts +++ b/packages/frontend/projects/upgrade/src/app/shared/directives/root-batch-actions.directive.ts @@ -10,11 +10,7 @@ import { CommonSimpleTextValidatedConfirmationModalComponent } from '../../share export function rootBatchView(state: RootBatchState, entity: BatchDeleteEntity) { const selection = selectionView(state, entity); - const messageKey = selection.busy - ? 'batch-delete.selection.busy' - : selection.reasonCode - ? `batch-delete.reason.${selection.reasonCode}` - : ''; + const messageKey = !selection.busy && selection.reasonCode ? `batch-delete.reason.${selection.reasonCode}` : ''; return { ...selection, messageKey, diff --git a/packages/frontend/projects/upgrade/src/app/shared/services/common-dialog.service.ts b/packages/frontend/projects/upgrade/src/app/shared/services/common-dialog.service.ts index 6e6e97b81..b3c3a3662 100644 --- a/packages/frontend/projects/upgrade/src/app/shared/services/common-dialog.service.ts +++ b/packages/frontend/projects/upgrade/src/app/shared/services/common-dialog.service.ts @@ -124,11 +124,6 @@ export class DialogService { `batch-delete.dialog.${entity}.${snapshot.items.length === 1 ? 'one' : 'other'}`, { count: snapshot.items.length } ), - subMessage: snapshot.notShownCount - ? this.translate.instant(`batch-delete.dialog.hidden.${snapshot.notShownCount === 1 ? 'one' : 'other'}`, { - count: snapshot.notShownCount, - }) - : undefined, validationKeyword: 'delete', validationPlaceholder: 'Type delete', isLoading$: facade.state$.pipe(map((state) => state.confirmation?.operationId !== snapshot.operationId)), diff --git a/packages/frontend/projects/upgrade/src/assets/i18n/en.json b/packages/frontend/projects/upgrade/src/assets/i18n/en.json index 0c70bd340..1fbf97623 100644 --- a/packages/frontend/projects/upgrade/src/assets/i18n/en.json +++ b/packages/frontend/projects/upgrade/src/assets/i18n/en.json @@ -8,7 +8,6 @@ "batch-delete.selection.experiments.row": "Select experiment {{name}}", "batch-delete.selection.flags.row": "Select feature flag {{name}}", "batch-delete.selection.segments.row": "Select segment {{name}}", - "batch-delete.selection.busy": "Deletion is in progress. Please wait for the result.", "batch-delete.dialog.experiments.title": "Delete Experiments", "batch-delete.dialog.experiments.one": "Are you sure you want to delete 1 experiment?", "batch-delete.dialog.experiments.other": "Are you sure you want to delete {{count}} experiments?", @@ -18,15 +17,13 @@ "batch-delete.dialog.segments.title": "Delete Segments", "batch-delete.dialog.segments.one": "Are you sure you want to delete 1 segment?", "batch-delete.dialog.segments.other": "Are you sure you want to delete {{count}} segments?", - "batch-delete.dialog.hidden.one": "1 selected item is not shown in the current list. It will also be deleted.", - "batch-delete.dialog.hidden.other": "{{count}} selected items are not shown in the current list. They will also be deleted.", "batch-delete.reason.missing_permission": "You do not have permission to delete the selected items.", "batch-delete.reason.experiment_state_unsupported": "A selected experiment cannot be deleted in its current state. Deselect it to enable deletion.", "batch-delete.reason.feature_flag_enabled": "A selected feature flag is enabled. Deselect it or disable it before deleting.", "batch-delete.reason.feature_flag_status_unsupported": "A selected feature flag cannot be deleted in its current state. Deselect it to enable deletion.", "batch-delete.reason.segment_in_use": "Some selected segments are in use. Deselect them to enable deletion.", "batch-delete.reason.protected_segment_type": "Only ordinary public segments can be deleted here.", - "batch-delete.reason.eligibility_unavailable": "Deletion eligibility is unavailable. Reload the page and review the selection.", + "batch-delete.reason.eligibility_unavailable": "Some selected items cannot be deleted.", "batch-delete.success.experiments.one": "1 experiment deleted.", "batch-delete.success.experiments.other": "{{deleted}} experiments deleted.", "batch-delete.success.flags.one": "1 feature flag deleted.", @@ -39,8 +36,8 @@ "batch-delete.result.failed.other": "{{count}} items could not be deleted.", "batch-delete.result.notAttempted.one": "1 item was not attempted.", "batch-delete.result.notAttempted.other": "{{count}} items were not attempted.", - "batch-delete.result.post-delete-failed": "Related updates could not be completed.", - "batch-delete.result.uncertain": "Some outcomes could not be confirmed. Check the list before retrying.", + "batch-delete.result.post-delete-failed": "An error occurred after deletion.", + "batch-delete.result.uncertain": "Deletion could not be confirmed for some items.", "global.app.name": "UpGrade", "global.app.description": "Open Source A/B Testing Platform for Education Software", From 82bc9d82606d935de661df7fe874a8a0420accf4 Mon Sep 17 00:00:00 2001 From: Zack Lee Date: Mon, 14 Sep 2026 22:42:47 -0400 Subject: [PATCH 24/61] refactor: remove unused batch deletion type exports --- .../upgrade/src/app/core/batch-actions/batch-actions.effects.ts | 2 +- packages/types/src/BatchActions/index.ts | 2 -- packages/types/src/index.ts | 1 - 3 files changed, 1 insertion(+), 4 deletions(-) diff --git a/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.effects.ts b/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.effects.ts index 00b20d985..6c3b1e0f9 100644 --- a/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.effects.ts +++ b/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.effects.ts @@ -17,7 +17,7 @@ import { RootBatchActions } from './batch-actions.actions'; import { RootBatchState, newBatchRequestId } from './batch-actions.models'; import { validateBatchResponse } from './batch-actions.helpers'; -export interface BatchDataSource { +interface BatchDataSource { batchDelete(ids: string[]): Observable; } diff --git a/packages/types/src/BatchActions/index.ts b/packages/types/src/BatchActions/index.ts index 248a383c0..5fb364d3f 100644 --- a/packages/types/src/BatchActions/index.ts +++ b/packages/types/src/BatchActions/index.ts @@ -28,8 +28,6 @@ export enum DeletionReasonCode { export type BatchDeleteEntity = 'experiments' | 'flags' | 'segments'; -export type BatchDeleteRequest = BatchEntityIdsRequest; - export type BatchDeleteItemOutcome = | 'deleted' | 'not_found' diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index 79b2e47e6..fe2864cf6 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -101,7 +101,6 @@ export { getExperimentDeletionReason, getFlagDeletionReason, DeletionReasonCode, - BatchDeleteRequest, BatchDeleteItemOutcome, BatchDeleteItemResult, BatchDeleteResult, From b4b4e3c0606afa3cac80f73e34094a4e23958637 Mon Sep 17 00:00:00 2001 From: Zack Lee Date: Mon, 14 Sep 2026 23:17:22 -0400 Subject: [PATCH 25/61] fix: restore row selection after failed root table queries --- .../batch-actions.integration.spec.ts | 35 +++++++++++++++++++ .../core/batch-actions/batch-actions.store.ts | 4 +++ 2 files changed, 39 insertions(+) diff --git a/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.integration.spec.ts b/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.integration.spec.ts index bba181c04..17d4f3452 100644 --- a/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.integration.spec.ts +++ b/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.integration.spec.ts @@ -218,6 +218,41 @@ describe.each(fixtures)('$entity batch store/effects integration', (config) => { expect(Object.keys(batch().selectedById)).toEqual([]); }); + it.each(['search', 'sort'])('restores selection controls after a failed replacement %s', (change) => { + selectRows(1); + const beforeRows = currentRows(); + const pendingList = new Subject(); + data[config.fetchMethod].mockReturnValueOnce(pendingList); + store.dispatch( + change === 'search' + ? config.actions.actionSetSearchString({ searchString: 'b' }) + : config.actions.actionSetSortingType({ sortingType: SORT_AS_DIRECTION.DESCENDING }) + ); + store.dispatch(config.fetch({ fromStarting: true })); + expect(batch().loadedIds).toEqual([]); + store.dispatch(actions.listFailed({ requestId: 'obsolete-request' })); + expect(batch().loadedIds).toEqual([]); + expect(batch().listLoading).toBe(true); + + pendingList.error({ status: 0 }); + expect(currentRows()).toEqual(beforeRows); + expect(batch().listLoading).toBe(false); + expect(state[config.key][config.loadingKey]).toBe(false); + expect(batch().loadedIds).toEqual(rows.map(({ id }) => id)); + expect(Object.keys(batch().selectedById)).toEqual([rows[0].id]); + + store.dispatch(actions.toggleRow({ item: selectionItem(rows[1]) })); + expect(Object.keys(batch().selectedById)).toEqual([rows[0].id, rows[1].id]); + store.dispatch(actions.toggleRow({ item: selectionItem(rows[0]) })); + expect(Object.keys(batch().selectedById)).toEqual([rows[1].id]); + store.dispatch(actions.toggleHeader({ items: rows.map(selectionItem) })); + expect(selectionView(batch(), config.entity).canToggleHeader).toBe(true); + store.dispatch(actions.toggleHeader({ items: rows.map(selectionItem) })); + expect(Object.keys(batch().selectedById)).toEqual(rows.map(({ id }) => id)); + expect(data[config.fetchMethod]).toHaveBeenCalledTimes(2); + expect(notifications.showWarning).not.toHaveBeenCalled(); + }); + it('selects loaded rows during incremental loading and leaves newly appended rows unselected', () => { data[config.fetchMethod].mockReturnValueOnce(of({ ...page(rows.slice(0, 2)), total: 3 })); store.dispatch(config.fetch({ fromStarting: true })); diff --git a/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.store.ts b/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.store.ts index d8a7359a7..56fff2aac 100644 --- a/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.store.ts +++ b/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.store.ts @@ -70,6 +70,10 @@ export function withRootBatch( prune(config.rowsKey); prune('allExperimentNames'); prune('listSegmentOptions'); + if (action.type === config.actions.listFailed.type && rootBatch !== state.rootBatch) { + // Failed replacement reads keep the displayed rows; their checkboxes must remain usable. + rootBatch = { ...rootBatch, loadedIds: result[config.rowsKey].map((row) => row.id) }; + } if (removed.has(result['selectedFlag']?.id)) result = { ...result, selectedFlag: null }; for (const key of ['stats', 'rewardsSummaries']) { if (result[key] && Object.keys(result[key]).some((id) => removed.has(id))) { From cecafaa2cf57b6d3d99137b52ef8af04dd04d83d Mon Sep 17 00:00:00 2001 From: Zack Lee Date: Tue, 15 Sep 2026 00:07:42 -0400 Subject: [PATCH 26/61] fix: await experiment deletion audit logs in the deletion transaction --- .../src/api/services/ExperimentService.ts | 7 +++- .../test/integration/BatchDelete/index.ts | 38 +++++++++++++++++++ 2 files changed, 44 insertions(+), 1 deletion(-) diff --git a/packages/backend/src/api/services/ExperimentService.ts b/packages/backend/src/api/services/ExperimentService.ts index ba20d690a..d7b48a726 100644 --- a/packages/backend/src/api/services/ExperimentService.ts +++ b/packages/backend/src/api/services/ExperimentService.ts @@ -455,7 +455,12 @@ export class ExperimentService { }; // Add log for experiment deleted - this.experimentAuditLogRepository.saveRawJson(LOG_TYPE.EXPERIMENT_DELETED, deleteAuditLogData, currentUser); + await this.experimentAuditLogRepository.saveRawJson( + LOG_TYPE.EXPERIMENT_DELETED, + deleteAuditLogData, + currentUser, + transactionalEntityManager + ); await Promise.all( experiment.experimentSegmentInclusion.map(async (segmentInclusion) => { diff --git a/packages/backend/test/integration/BatchDelete/index.ts b/packages/backend/test/integration/BatchDelete/index.ts index 2938eb1dc..9fab0750e 100644 --- a/packages/backend/test/integration/BatchDelete/index.ts +++ b/packages/backend/test/integration/BatchDelete/index.ts @@ -11,6 +11,7 @@ import { DeletionReasonCode, EXPERIMENT_STATE, FEATURE_FLAG_STATUS, + LOG_TYPE, POST_EXPERIMENT_RULE, SEGMENT_TYPE, STANDARD_LIST_TYPE, @@ -23,6 +24,7 @@ import { SegmentController } from '../../../src/api/controllers/SegmentControlle import { ErrorHandlerMiddleware } from '../../../src/api/middlewares/ErrorHandlerMiddleware'; import { LogMiddleware } from '../../../src/api/middlewares/LogMiddleware'; import { Experiment } from '../../../src/api/models/Experiment'; +import { ExperimentAuditLog } from '../../../src/api/models/ExperimentAuditLog'; import { ExperimentSegmentInclusion } from '../../../src/api/models/ExperimentSegmentInclusion'; import { FeatureFlag } from '../../../src/api/models/FeatureFlag'; import { FeatureFlagSegmentInclusion } from '../../../src/api/models/FeatureFlagSegmentInclusion'; @@ -331,6 +333,36 @@ export function registerBatchDeleteTests(connections: () => [DataSource, DataSou } }); + test('rolls back an experiment after an audit insert failure and stops the remaining items', async () => { + const rows = await create('experiments', 3); + const list = await ownedList('experiments', rows[1].id); + try { + await db.query(`CREATE OR REPLACE FUNCTION batch_test_reject_audit() RETURNS trigger AS $$ + BEGIN IF NEW.data->>'experimentId' = TG_ARGV[0] THEN RAISE EXCEPTION 'batch audit failure'; END IF; + RETURN NEW; END; $$ LANGUAGE plpgsql`); + // The interpolated UUID is generated only by this fixture. + await db.query(`CREATE TRIGGER batch_test_reject_audit BEFORE INSERT ON experiment_audit_log + FOR EACH ROW EXECUTE FUNCTION batch_test_reject_audit('${rows[1].id}')`); + const { body } = await request(app) + .post(route('experiments')) + .send({ ids: rows.map((row) => row.id) }) + .expect(200); + expect(body.results).toEqual([ + { id: rows[0].id, outcome: 'deleted' }, + { id: rows[1].id, outcome: 'failed', reasonCode: DeletionReasonCode.DELETE_FAILED }, + { id: rows[2].id, outcome: 'not_attempted' }, + ]); + expect(await db.getRepository(Experiment).countBy({ id: rows[0].id })).toBe(0); + expect(await db.getRepository(Experiment).countBy({ id: In(rows.slice(1).map((row) => row.id)) })).toBe(2); + expect(await db.getRepository(Segment).countBy({ id: list.id })).toBe(1); + expect(await db.getRepository(ExperimentSegmentInclusion).countBy({ segmentId: list.id })).toBe(1); + expect(await db.getRepository(ExperimentAuditLog).countBy({ type: LOG_TYPE.EXPERIMENT_DELETED })).toBe(1); + } finally { + await db.query('DROP TRIGGER IF EXISTS batch_test_reject_audit ON experiment_audit_log'); + await db.query('DROP FUNCTION IF EXISTS batch_test_reject_audit()'); + } + }); + test('bounds a guard lock wait and reports a confirmed failure without deleting', async () => { const [row] = await create('flags'); const blocker = writer.createQueryRunner(); @@ -419,6 +451,9 @@ export function registerBatchDeleteTests(connections: () => [DataSource, DataSou expect(remote).toHaveBeenCalledWith(ref, expect.anything()); expect(await db.getRepository(Experiment).countBy({ id: row.id })).toBe(success ? 0 : 1); expect(await db.getRepository(Segment).countBy({ id: list.id })).toBe(success ? 0 : 1); + expect(await db.getRepository(ExperimentAuditLog).countBy({ type: LOG_TYPE.EXPERIMENT_DELETED })).toBe( + success ? 1 : 0 + ); } ); @@ -587,6 +622,9 @@ export function registerBatchDeleteTests(connections: () => [DataSource, DataSou expect(remote).toHaveBeenCalledTimes(1); expect(await db.getRepository(Experiment).countBy({ id: row.id })).toBe(success ? 0 : 1); expect(await db.getRepository(Segment).countBy({ id: list.id })).toBe(success ? 0 : 1); + expect(await db.getRepository(ExperimentAuditLog).countBy({ type: LOG_TYPE.EXPERIMENT_DELETED })).toBe( + success ? 1 : 0 + ); } ); From 8ebe3f4241d8272abde995d7125f0fd4167d84e3 Mon Sep 17 00:00:00 2001 From: Zack Lee Date: Tue, 15 Sep 2026 00:38:25 -0400 Subject: [PATCH 27/61] fix: preserve selection and detail views during batch deletion --- .../batch-actions.integration.spec.ts | 79 +++++++++++++++++-- .../batch-actions/batch-actions.reducer.ts | 5 +- .../core/batch-actions/batch-actions.store.ts | 7 +- .../experiments/store/experiments.effects.ts | 6 +- .../core/segments/store/segments.effects.ts | 6 +- 5 files changed, 92 insertions(+), 11 deletions(-) diff --git a/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.integration.spec.ts b/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.integration.spec.ts index 17d4f3452..ac95345cb 100644 --- a/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.integration.spec.ts +++ b/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.integration.spec.ts @@ -19,6 +19,8 @@ import * as segmentActions from '../segments/store/segments.actions'; import { experimentsReducer } from '../experiments/store/experiments.reducer'; import { featureFlagsReducer } from '../feature-flags/store/feature-flags.reducer'; import { segmentsReducer } from '../segments/store/segments.reducer'; +import { selectSelectedExperiment } from '../experiments/store/experiments.selectors'; +import { selectSelectedSegment } from '../segments/store/segments.selectors'; import { ExperimentEffects } from '../experiments/store/experiments.effects'; import { FeatureFlagsEffects } from '../feature-flags/store/feature-flags.effects'; import { FeatureFlagsService } from '../feature-flags/feature-flags.service'; @@ -31,6 +33,7 @@ import { RootBatchState, newBatchRequestId } from './batch-actions.models'; const fixtures = [ { entity: 'experiments', + rootPath: '/home', key: 'experiments', loadingKey: 'isLoadingExperiment', actions: experimentActions, @@ -40,6 +43,7 @@ const fixtures = [ }, { entity: 'flags', + rootPath: '/featureflags', key: 'featureFlags', loadingKey: 'isLoadingFeatureFlags', actions: flagActions, @@ -49,6 +53,7 @@ const fixtures = [ }, { entity: 'segments', + rootPath: '/segments', key: 'segments', loadingKey: 'isLoadingSegments', actions: segmentActions, @@ -122,7 +127,7 @@ describe.each(fixtures)('$entity batch store/effects integration', (config) => { batchDelete: jest.fn(() => response), [config.fetchMethod]: jest.fn(() => of(page(rows.filter((row) => !batch().removedIds.includes(row.id))))), }; - router = { navigate: jest.fn() }; + router = { url: config.rootPath, navigate: jest.fn() }; notifications = { showSuccess: jest.fn(), showWarning: jest.fn(), showError: jest.fn(), showInfo: jest.fn() }; const translate = TestBed.inject(TranslateService); translate.setTranslation('en', jest.requireActual('../../../assets/i18n/en.json')); @@ -493,18 +498,20 @@ describe.each(fixtures)('$entity batch store/effects integration', (config) => { }); it.each([0, 400, 403, 500, 504])( - 'clears cancelled list loading and releases selection after HTTP %i without another request or snackbar', + 'restores cancelled replacement rows after HTTP %i without another request or snackbar', (status) => { selectRows(); - const snapshot = prepare(); const pendingList = new Subject(); data[config.fetchMethod].mockReturnValueOnce(pendingList); + store.dispatch(config.actions.actionSetSearchString({ searchString: 'b' })); store.dispatch(config.fetch({ fromStarting: true })); expect(state[config.key][config.loadingKey]).toBe(true); + expect(batch().loadedIds).toEqual([]); const fetchCount = data[config.fetchMethod].mock.calls.length; const beforeRows = currentRows(); - const loadedIds = [...batch().loadedIds]; + const snapshot = prepare(); store.dispatch(actions.batchDeleteRequested({ snapshot })); + expect(pendingList.observed).toBe(false); response.error({ status }); pendingList.next(page([])); pendingList.complete(); @@ -512,7 +519,7 @@ describe.each(fixtures)('$entity batch store/effects integration', (config) => { expect(batch().listLoading).toBe(false); expect(state[config.key][config.loadingKey]).toBe(false); expect(currentRows()).toEqual(beforeRows); - expect(batch().loadedIds).toEqual(loadedIds); + expect(batch().loadedIds).toEqual(beforeRows.map(({ id }) => id)); expect(data[config.fetchMethod]).toHaveBeenCalledTimes(fetchCount); expect(notifications.showWarning).not.toHaveBeenCalled(); @@ -530,6 +537,68 @@ describe.each(fixtures)('$entity batch store/effects integration', (config) => { } ); + if (config.entity !== 'flags') { + it.each(['deletion', 'refresh'])('preserves open details when leaving during %s', (pending) => { + selectRows(1); + const snapshot = prepare(); + store.dispatch(actions.batchDeleteRequested({ snapshot })); + const pendingRefresh = new Subject(); + data[config.fetchMethod].mockReturnValue(pendingRefresh); + const result = { phase: 'executed', results: [{ id: rows[0].id, outcome: 'deleted' }] }; + if (pending === 'refresh') { + response.next(result); + expect(pendingRefresh.observed).toBe(true); + } + + const viewed = { ...rows[2], description: 'Loaded detail' }; + router.url = `${config.rootPath}/detail/${viewed.id}`; + store.dispatch(actions.rootPageLeft()); + if (config.entity === 'experiments') { + store.dispatch(experimentActions.actionGetExperimentByIdSuccess({ experiment: viewed as any })); + } else { + store.dispatch( + segmentActions.actionGetSegmentByIdSuccess({ + segment: viewed as any, + experimentSegmentInclusion: [], + experimentSegmentExclusion: [], + featureFlagSegmentInclusion: [], + featureFlagSegmentExclusion: [], + allParentSegments: [], + }) + ); + } + const selectedDetail = () => + config.entity === 'experiments' + ? selectSelectedExperiment.projector( + { state: { params: { experimentId: viewed.id } } } as any, + state.experiments + ) + : selectSelectedSegment.projector( + { state: { params: { segmentId: viewed.id } } } as any, + state.segments.segments + ); + expect(selectedDetail()?.id).toBe(viewed.id); + if (pending === 'deletion') response.next(result); + pendingRefresh.next(page([rows[1]])); + pendingRefresh.complete(); + expect(selectedDetail()?.id).toBe(viewed.id); + expect(batch().operation.status).toBe('complete'); + expect(batch().removedIds).toContain(rows[0].id); + expect(batch().listLoading).toBe(false); + expect(state[config.key][config.loadingKey]).toBe(false); + expect(Object.keys(batch().selectedById)).toEqual([]); + expect(data[config.fetchMethod]).toHaveBeenCalledTimes(pending === 'refresh' ? 2 : 1); + expect(notifications.showSuccess).toHaveBeenCalledTimes(1); + expect(router.navigate).not.toHaveBeenCalled(); + + router.url = `${config.rootPath}?view=all#table`; + data[config.fetchMethod].mockReturnValue(of(page([rows[1], viewed]))); + store.dispatch(config.fetch({ fromStarting: true })); + expect(currentRows().map(({ id }) => id)).toEqual([rows[1].id, viewed.id]); + expect(batch().loadedIds).toEqual([rows[1].id, viewed.id]); + }); + } + it('retains every selection when the server reports all items as ineligible', () => { selectRows(); const snapshot = prepare(); diff --git a/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.reducer.ts b/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.reducer.ts index 12e5066aa..ba34fe0b0 100644 --- a/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.reducer.ts +++ b/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.reducer.ts @@ -78,8 +78,9 @@ export function reduceRootBatch( if (matches(action, actions.listFailed)) return action.requestId === state.listRequestId ? { ...state, listLoading: false } : state; if (matches(action, actions.confirmedRemoved)) return removeConfirmed(state, action.ids); - // Navigation clears the UI selection, but keeps any submitted operation and its result tracking alive. - if (matches(action, actions.rootPageLeft)) return { ...invalidateSelection(state), selectedById: {} }; + // Cancel root reads before they can replace detail data; submitted deletion remains observable. + if (matches(action, actions.rootPageLeft)) + return { ...invalidateSelection(state), selectedById: {}, listRequestId: null, listLoading: false }; if (matches(action, actions.toggleHeader) || matches(action, actions.toggleRow)) { if (isBatchBusy(state)) return state; let selectedById = { ...state.selectedById }; diff --git a/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.store.ts b/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.store.ts index 56fff2aac..28af67997 100644 --- a/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.store.ts +++ b/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.store.ts @@ -70,8 +70,11 @@ export function withRootBatch( prune(config.rowsKey); prune('allExperimentNames'); prune('listSegmentOptions'); - if (action.type === config.actions.listFailed.type && rootBatch !== state.rootBatch) { - // Failed replacement reads keep the displayed rows; their checkboxes must remain usable. + if ( + (action.type === config.actions.listFailed.type || action.type === config.actions.batchDeleteRequested.type) && + rootBatch !== state.rootBatch + ) { + // Failed reads and reads cancelled by submission leave the displayed rows available for selection. rootBatch = { ...rootBatch, loadedIds: result[config.rowsKey].map((row) => row.id) }; } if (removed.has(result['selectedFlag']?.id)) result = { ...result, selectedFlag: null }; 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 4be6f8963..40129af4a 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 @@ -56,8 +56,12 @@ export class ExperimentEffects { const message = batchResultMessage('experiments', counts, (key, params) => this.translate.instant(key, params)); if (hasWarnings) this.notificationService.showWarning(message); else this.notificationService.showSuccess(message); + const pathname = (this.router.url || '').split('?')[0].split('#')[0]; return [ - experimentAction.actionGetExperiments({ fromStarting: true, batchRefresh: true }), + // Detail selectors share the rows array; replace it only while the root table is displayed. + ...(pathname === '/home' + ? [experimentAction.actionGetExperiments({ fromStarting: true, batchRefresh: true })] + : []), ...(counts.deleted || counts.absent ? [ experimentAction.actionFetchAllDecisionPoints(), 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 a92491938..ec7f401bc 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 @@ -40,8 +40,12 @@ export class SegmentsEffects { const message = batchResultMessage('segments', counts, (key, params) => this.translate.instant(key, params)); if (hasWarnings) this.notificationService.showWarning(message); else this.notificationService.showSuccess(message); + const pathname = (this.router.url || '').split('?')[0].split('#')[0]; return [ - SegmentsActions.actionFetchSegments({ fromStarting: true, batchRefresh: true }), + // Detail selectors share the rows array; replace it only while the root table is displayed. + ...(pathname === '/segments' + ? [SegmentsActions.actionFetchSegments({ fromStarting: true, batchRefresh: true })] + : []), ...(counts.deleted || counts.absent ? [SegmentsActions.actionFetchListSegmentOptions()] : []), ]; } From c677a0c17ab32ecceddc5ec7bc1b121054e3da84 Mon Sep 17 00:00:00 2001 From: Zack Lee Date: Tue, 15 Sep 2026 00:55:27 -0400 Subject: [PATCH 28/61] fix: keep feature flag deletion audits in the deletion transaction --- .../src/api/services/FeatureFlagService.ts | 3 ++- .../test/integration/BatchDelete/index.ts | 19 ++++++++++++++----- 2 files changed, 16 insertions(+), 6 deletions(-) diff --git a/packages/backend/src/api/services/FeatureFlagService.ts b/packages/backend/src/api/services/FeatureFlagService.ts index 32d8a9c5d..3c763c1d9 100644 --- a/packages/backend/src/api/services/FeatureFlagService.ts +++ b/packages/backend/src/api/services/FeatureFlagService.ts @@ -384,7 +384,8 @@ export class FeatureFlagService { await this.experimentAuditLogRepository.saveRawJson( LOG_TYPE.FEATURE_FLAG_DELETED, createAuditLogData, - currentUser + currentUser, + transactionalEntityManager ); return deletedFlag; } diff --git a/packages/backend/test/integration/BatchDelete/index.ts b/packages/backend/test/integration/BatchDelete/index.ts index 9fab0750e..999a30aa3 100644 --- a/packages/backend/test/integration/BatchDelete/index.ts +++ b/packages/backend/test/integration/BatchDelete/index.ts @@ -299,17 +299,21 @@ export function registerBatchDeleteTests(connections: () => [DataSource, DataSou } ); - test('reports an owned-list cleanup rollback after a committed success and stops the remaining items', async () => { + test.each(['cleanup', 'commit'])('rolls back flag deletion when %s fails', async (failureStage) => { const rows = await create('flags', 3); const list = await ownedList('flags', rows[1].id); + const atCommit = failureStage === 'commit'; try { await db.query(`CREATE OR REPLACE FUNCTION batch_test_reject_delete() RETURNS trigger AS $$ - BEGIN IF OLD.id = TG_ARGV[0]::uuid THEN RAISE EXCEPTION 'batch cleanup failure'; END IF; + BEGIN IF OLD.id = TG_ARGV[0]::uuid THEN RAISE EXCEPTION 'batch deletion failure'; END IF; RETURN OLD; END; $$ LANGUAGE plpgsql`); await db.query('DROP TRIGGER IF EXISTS batch_test_reject_delete ON segment'); + // Deferring this test-only trigger forces a commit failure after the audit has been written. + const trigger = atCommit + ? 'CREATE CONSTRAINT TRIGGER batch_test_reject_delete AFTER DELETE ON segment DEFERRABLE INITIALLY DEFERRED' + : 'CREATE TRIGGER batch_test_reject_delete BEFORE DELETE ON segment'; // The interpolated UUID is generated only by this fixture. - await db.query(`CREATE TRIGGER batch_test_reject_delete BEFORE DELETE ON segment - FOR EACH ROW EXECUTE FUNCTION batch_test_reject_delete('${list.id}')`); + await db.query(`${trigger} FOR EACH ROW EXECUTE FUNCTION batch_test_reject_delete('${list.id}')`); const { body } = await request(app) .post(route('flags')) .send({ ids: rows.map((row) => row.id) }) @@ -318,7 +322,11 @@ export function registerBatchDeleteTests(connections: () => [DataSource, DataSou phase: 'executed', results: [ { id: rows[0].id, outcome: 'deleted' }, - { id: rows[1].id, outcome: 'failed', reasonCode: DeletionReasonCode.DELETE_FAILED }, + { + id: rows[1].id, + outcome: atCommit ? 'unknown' : 'failed', + reasonCode: atCommit ? DeletionReasonCode.OUTCOME_UNKNOWN : DeletionReasonCode.DELETE_FAILED, + }, { id: rows[2].id, outcome: 'not_attempted' }, ], }); @@ -327,6 +335,7 @@ export function registerBatchDeleteTests(connections: () => [DataSource, DataSou expect(await db.getRepository(Segment).countBy({ id: list.id })).toBe(1); expect(await db.getRepository(IndividualForSegment).countBy({ segmentId: list.id })).toBe(1); expect(await db.getRepository(FeatureFlagSegmentInclusion).countBy({ segmentId: list.id })).toBe(1); + expect(await db.getRepository(ExperimentAuditLog).countBy({ type: LOG_TYPE.FEATURE_FLAG_DELETED })).toBe(1); } finally { await db.query('DROP TRIGGER IF EXISTS batch_test_reject_delete ON segment'); await db.query('DROP FUNCTION IF EXISTS batch_test_reject_delete()'); From f3a1616b6707744e650035ab93d1682d8952f751 Mon Sep 17 00:00:00 2001 From: Zack Lee Date: Tue, 15 Sep 2026 02:09:42 -0400 Subject: [PATCH 29/61] refactor: remove redundant batch deletion refreshes and unused outcomes --- .../src/api/controllers/SegmentController.ts | 2 +- .../core/batch-actions/batch-actions.helpers.ts | 4 ++-- .../batch-actions.integration.spec.ts | 10 ++++++++++ .../core/batch-actions/batch-actions.reducer.ts | 14 -------------- .../core/experiments/store/experiments.effects.ts | 8 +------- .../feature-flags/store/feature-flags.effects.ts | 6 +----- .../app/core/segments/store/segments.effects.ts | 1 - packages/types/src/BatchActions/index.ts | 9 +-------- 8 files changed, 16 insertions(+), 38 deletions(-) diff --git a/packages/backend/src/api/controllers/SegmentController.ts b/packages/backend/src/api/controllers/SegmentController.ts index a02e7b5d2..a29de65d0 100644 --- a/packages/backend/src/api/controllers/SegmentController.ts +++ b/packages/backend/src/api/controllers/SegmentController.ts @@ -65,7 +65,7 @@ interface SegmentPaginationInfo extends PaginationResponse { * format: uuid * outcome: * type: string - * enum: [deleted, not_found, ineligible, forbidden, failed, unknown, not_attempted] + * enum: [deleted, not_found, ineligible, failed, unknown, not_attempted] * reasonCode: * type: string * enum: [not_found, missing_permission, experiment_state_unsupported, feature_flag_enabled, feature_flag_status_unsupported, segment_in_use, protected_segment_type, eligibility_unavailable, delete_failed, lock_timeout, external_sync_failed, outcome_unknown, post_delete_failed, batch_budget_exceeded] diff --git a/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.helpers.ts b/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.helpers.ts index 35f98cf8c..b4a020304 100644 --- a/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.helpers.ts +++ b/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.helpers.ts @@ -61,7 +61,7 @@ export const confirmedRemovedIds = (result?: BatchDeleteResult) => []; export function validateBatchResponse(result: BatchDeleteResult, ids: string[]): BatchDeleteResult { - const outcomes = ['deleted', 'not_found', 'ineligible', 'forbidden', 'failed', 'unknown', 'not_attempted']; + const outcomes = ['deleted', 'not_found', 'ineligible', 'failed', 'unknown', 'not_attempted']; if ( !result || !['rejected', 'executed'].includes(result.phase) || @@ -82,7 +82,7 @@ export function batchResultCounts(state: RootBatchState) { deleted: results.filter((item) => item.outcome === 'deleted').length, absent: results.filter((item) => item.outcome === 'not_found').length, uncertain: results.some((item) => item.outcome === 'unknown'), - failed: results.filter((item) => ['failed', 'ineligible', 'forbidden'].includes(item.outcome)).length, + failed: results.filter((item) => ['failed', 'ineligible'].includes(item.outcome)).length, notAttempted: results.filter((item) => item.outcome === 'not_attempted').length, hasErrors: results.some((item) => item.outcome !== 'deleted' || item.reasonCode), postDeleteFailed: results.some((item) => item.reasonCode === DeletionReasonCode.POST_DELETE_FAILED), diff --git a/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.integration.spec.ts b/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.integration.spec.ts index ac95345cb..566ddeb6c 100644 --- a/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.integration.spec.ts +++ b/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.integration.spec.ts @@ -474,6 +474,9 @@ describe.each(fixtures)('$entity batch store/effects integration', (config) => { })); it.each([1, 3])('reports %i deletions once and ignores stale reads started before or during deletion', (count) => { + const options = rows.map(({ id, name }) => ({ id, name, context: 'home' })); + store.dispatch(experimentActions.actionFetchAllExperimentNamesSuccess({ allExperimentNames: options })); + store.dispatch(segmentActions.actionFetchListSegmentOptionsSuccess({ listSegmentOptions: options })); selectRows(count); const snapshot = prepare(); const pending = new Subject(); @@ -492,6 +495,13 @@ describe.each(fixtures)('$entity batch store/effects integration', (config) => { ) as any; store.dispatch({ ...oldSuccess, batchListRequestId: oldRequestId }); expect(currentRows().some((row) => row.id === rows[0].id)).toBe(false); + expect(state.experiments.allExperimentNames).toEqual( + config.entity === 'experiments' ? options.slice(count) : options + ); + expect(state.segments.listSegmentOptions).toEqual(config.entity === 'segments' ? options.slice(count) : options); + const eventTypes = events.map((action) => action.type); + expect(eventTypes).not.toContain(experimentActions.actionFetchAllExperimentNames.type); + expect(eventTypes).not.toContain(segmentActions.actionFetchListSegmentOptions.type); expect(notifications.showSuccess).toHaveBeenCalledTimes(1); const noun = { experiments: 'experiment', flags: 'feature flag', segments: 'segment' }[config.entity]; expect(notifications.showSuccess).toHaveBeenCalledWith(`${count} ${noun}${count === 1 ? '' : 's'} deleted.`); diff --git a/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.reducer.ts b/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.reducer.ts index ba34fe0b0..7e994691a 100644 --- a/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.reducer.ts +++ b/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.reducer.ts @@ -165,7 +165,6 @@ export function reduceRootBatch( }; } if (matches(action, actions.batchDeleteRequestFailed)) { - const rejected = [400, 401, 403].includes(action.status); return { ...invalidateSelection(state), operation: { @@ -173,19 +172,6 @@ export function reduceRootBatch( transportStatus: action.status, // The shared HTTP interceptor reports request errors. Release controls without follow-up requests. status: 'complete', - result: { - phase: rejected ? 'rejected' : 'executed', - results: state.operation.snapshot.items.map(({ id }) => ({ - id, - outcome: action.status === 403 ? 'forbidden' : rejected ? 'not_attempted' : 'unknown', - reasonCode: - action.status === 403 - ? DeletionReasonCode.MISSING_PERMISSION - : rejected - ? DeletionReasonCode.DELETE_FAILED - : DeletionReasonCode.OUTCOME_UNKNOWN, - })), - }, }, }; } 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 40129af4a..cdb84ff81 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 @@ -1,7 +1,6 @@ import { batchDeleteEffect, batchFinishedEffect, trackedListRequest } from '../../batch-actions/batch-actions.effects'; import { batchResultCounts, batchResultMessage } from '../../batch-actions/batch-actions.helpers'; import { selectRootBatch, selectExperimentState } from './experiments.selectors'; -import { actionFetchListSegmentOptions } from '../../segments/store/segments.actions'; import { Inject, Injectable } from '@angular/core'; import { Actions, createEffect, ofType } from '@ngrx/effects'; import * as experimentAction from './experiments.actions'; @@ -63,12 +62,7 @@ export class ExperimentEffects { ? [experimentAction.actionGetExperiments({ fromStarting: true, batchRefresh: true })] : []), ...(counts.deleted || counts.absent - ? [ - experimentAction.actionFetchAllDecisionPoints(), - analysisActions.actionFetchMetrics(), - experimentAction.actionFetchAllExperimentNames(), - actionFetchListSegmentOptions(), - ] + ? [experimentAction.actionFetchAllDecisionPoints(), analysisActions.actionFetchMetrics()] : []), ]; } 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 4d08fc804..bbe953535 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 @@ -1,7 +1,6 @@ import { batchDeleteEffect, batchFinishedEffect, trackedListRequest } from '../../batch-actions/batch-actions.effects'; import { batchResultCounts, batchResultMessage } from '../../batch-actions/batch-actions.helpers'; import { selectRootBatch, selectFeatureFlagsState } from './feature-flags.selectors'; -import { actionFetchListSegmentOptions } from '../../segments/store/segments.actions'; import { FeatureFlagsDataService } from '../feature-flags.data.service'; import { Actions, createEffect, ofType } from '@ngrx/effects'; import { Injectable } from '@angular/core'; @@ -43,10 +42,7 @@ export class FeatureFlagsEffects { const message = batchResultMessage('flags', counts, (key, params) => this.translate.instant(key, params)); if (hasWarnings) this.notificationService.showWarning(message); else this.notificationService.showSuccess(message); - return [ - FeatureFlagsActions.actionFetchFeatureFlags({ fromStarting: true, batchRefresh: true }), - ...(counts.deleted || counts.absent ? [actionFetchListSegmentOptions()] : []), - ]; + return [FeatureFlagsActions.actionFetchFeatureFlags({ fromStarting: true, batchRefresh: 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 ec7f401bc..efe9ff12b 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 @@ -46,7 +46,6 @@ export class SegmentsEffects { ...(pathname === '/segments' ? [SegmentsActions.actionFetchSegments({ fromStarting: true, batchRefresh: true })] : []), - ...(counts.deleted || counts.absent ? [SegmentsActions.actionFetchListSegmentOptions()] : []), ]; } ) diff --git a/packages/types/src/BatchActions/index.ts b/packages/types/src/BatchActions/index.ts index 5fb364d3f..2ab945ae4 100644 --- a/packages/types/src/BatchActions/index.ts +++ b/packages/types/src/BatchActions/index.ts @@ -28,14 +28,7 @@ export enum DeletionReasonCode { export type BatchDeleteEntity = 'experiments' | 'flags' | 'segments'; -export type BatchDeleteItemOutcome = - | 'deleted' - | 'not_found' - | 'ineligible' - | 'forbidden' - | 'failed' - | 'unknown' - | 'not_attempted'; +export type BatchDeleteItemOutcome = 'deleted' | 'not_found' | 'ineligible' | 'failed' | 'unknown' | 'not_attempted'; export interface BatchDeleteItemResult { id: string; From 0c6926feeb309f151ecee46a91458037731c01a6 Mon Sep 17 00:00:00 2001 From: Zack Lee Date: Wed, 16 Sep 2026 10:26:57 -0400 Subject: [PATCH 30/61] refactor: remove backend deletion eligibility guards --- .../api/controllers/ExperimentController.ts | 24 +- .../api/controllers/FeatureFlagController.ts | 15 +- .../src/api/controllers/SegmentController.ts | 21 +- .../api/repositories/DeletionRepository.ts | 106 +---- .../api/repositories/ExperimentRepository.ts | 12 +- .../api/repositories/FeatureFlagRepository.ts | 12 +- .../src/api/repositories/SegmentRepository.ts | 12 - .../src/api/services/DeletionStateService.ts | 94 ---- .../src/api/services/SegmentService.ts | 8 +- .../api/services/batch/BatchDeleteService.ts | 76 +--- .../batch/DeletionEligibilityService.ts | 95 ---- .../services/batch/SegmentDeletionGuard.ts | 39 -- .../test/integration/BatchDelete/index.ts | 273 ++++-------- .../integration/SegmentDeletionGuard/index.ts | 406 ------------------ .../backend/test/integration/index.test.ts | 2 - .../controllers/ExperimentController.test.ts | 2 - .../controllers/FeatureFlagController.test.ts | 2 - .../controllers/SegmentController.test.ts | 2 - .../services/batch/BatchDeleteService.test.ts | 125 ++---- .../batch/DeletionEligibilityService.test.ts | 111 ----- .../batch-actions.helpers.spec.ts | 38 +- .../batch-actions/batch-actions.helpers.ts | 29 +- .../batch-actions.integration.spec.ts | 108 +---- .../batch-actions/batch-actions.models.ts | 11 +- .../batch-actions/batch-actions.reducer.ts | 15 +- .../dashboard/batch-delete-ui.spec.ts | 45 +- .../projects/upgrade/src/assets/i18n/en.json | 1 - packages/types/src/BatchActions/index.ts | 40 +- packages/types/src/index.ts | 2 - 29 files changed, 247 insertions(+), 1479 deletions(-) delete mode 100644 packages/backend/src/api/services/DeletionStateService.ts delete mode 100644 packages/backend/src/api/services/batch/DeletionEligibilityService.ts delete mode 100644 packages/backend/src/api/services/batch/SegmentDeletionGuard.ts delete mode 100644 packages/backend/test/integration/SegmentDeletionGuard/index.ts delete mode 100644 packages/backend/test/unit/services/batch/DeletionEligibilityService.test.ts diff --git a/packages/backend/src/api/controllers/ExperimentController.ts b/packages/backend/src/api/controllers/ExperimentController.ts index aeda4a193..fd9e51c56 100644 --- a/packages/backend/src/api/controllers/ExperimentController.ts +++ b/packages/backend/src/api/controllers/ExperimentController.ts @@ -2,7 +2,6 @@ import { BatchDeleteResult } from 'upgrade_types'; import { Inject } from 'typedi'; import { BatchDeleteService } from '../services/batch/BatchDeleteService'; import { BatchEntityIdsValidator } from './validators/BatchEntityIdsValidator'; -import { DeletionStateService } from '../services/DeletionStateService'; import { Body, Get, @@ -670,8 +669,7 @@ export class ExperimentController { public moocletRewardService: MoocletRewardsService, public importExportService: ImportExportService, public cacheService: CacheService, - @Inject(() => BatchDeleteService) private batchDeleteService: BatchDeleteService, - private deletionStateService: DeletionStateService + @Inject(() => BatchDeleteService) private batchDeleteService: BatchDeleteService ) {} /** @@ -679,7 +677,7 @@ export class ExperimentController { * /experiments/batch-delete: * post: * summary: Delete the selected experiments - * description: Checks the selection, skips missing or ineligible items, and deletes eligible items sequentially. Other execution failures stop the remaining items. Returns one result per ID. + * description: Deletes selected experiments sequentially regardless of state, skipping missing items. Execution failures stop the remaining items. Returns one result per ID. * tags: * - Experiments * parameters: @@ -1180,7 +1178,7 @@ export class ExperimentController { * schema: * $ref: '#/definitions/ExperimentResponse' * '400': - * description: Invalid UUID or experiment state does not allow deletion. + * description: Invalid UUID. * '401': * description: AuthorizationRequiredError * '404': @@ -1197,23 +1195,21 @@ export class ExperimentController { ): Promise { request.logger.child({ user: currentUser }); - const executeTransaction = this.deletionStateService.transactionFor('experiments', id); // Manually check if the experiment has a mooclet ref if (env.mooclets.enabled) { const moocletExperimentRef = await this.moocletExperimentService.getMoocletExperimentRefByUpgradeExperimentId(id); if (moocletExperimentRef) { - return await this.moocletExperimentService.syncDelete( - { moocletExperimentRef, experimentId: id, currentUser, logger: request.logger }, - executeTransaction - ); + return await this.moocletExperimentService.syncDelete({ + moocletExperimentRef, + experimentId: id, + currentUser, + logger: request.logger, + }); } } - const experiment = await this.experimentService.delete(id, currentUser, { - logger: request.logger, - executeTransaction, - }); + const experiment = await this.experimentService.delete(id, currentUser, { logger: request.logger }); if (!experiment) { throw new NotFoundException('Experiment not found.'); diff --git a/packages/backend/src/api/controllers/FeatureFlagController.ts b/packages/backend/src/api/controllers/FeatureFlagController.ts index e0329b451..8acd4a88d 100644 --- a/packages/backend/src/api/controllers/FeatureFlagController.ts +++ b/packages/backend/src/api/controllers/FeatureFlagController.ts @@ -2,7 +2,6 @@ import { BatchDeleteResult } from 'upgrade_types'; import { Inject } from 'typedi'; import { BatchDeleteService } from '../services/batch/BatchDeleteService'; import { BatchEntityIdsValidator } from './validators/BatchEntityIdsValidator'; -import { DeletionStateService } from '../services/DeletionStateService'; import { JsonController, Authorized, @@ -162,8 +161,7 @@ export class FeatureFlagsController { constructor( public featureFlagService: FeatureFlagService, public experimentUserService: ExperimentUserService, - @Inject(() => BatchDeleteService) private batchDeleteService: BatchDeleteService, - private deletionStateService: DeletionStateService + @Inject(() => BatchDeleteService) private batchDeleteService: BatchDeleteService ) {} /** @@ -171,7 +169,7 @@ export class FeatureFlagsController { * /flags/batch-delete: * post: * summary: Delete the selected flags - * description: Checks the selection, skips missing or ineligible items, and deletes eligible items sequentially. Other execution failures stop the remaining items. Returns one result per ID. + * description: Deletes selected feature flags sequentially regardless of status, skipping missing items. Execution failures stop the remaining items. Returns one result per ID. * tags: * - Feature Flags * parameters: @@ -469,7 +467,7 @@ export class FeatureFlagsController { * '200': * description: Delete Feature flag By Id * '400': - * description: Invalid UUID or feature flag is neither Disabled nor Archived + * description: Invalid UUID * '404': * description: Feature flag not found */ @@ -480,12 +478,7 @@ export class FeatureFlagsController { @CurrentUser() currentUser: UserDTO, @Req() request: AppRequest ): Promise { - const featureFlag = await this.featureFlagService.delete( - id, - currentUser, - request.logger, - this.deletionStateService.transactionFor('flags', id) - ); + const featureFlag = await this.featureFlagService.delete(id, currentUser, request.logger); if (!featureFlag) { throw new NotFoundException('Feature flag not found.'); diff --git a/packages/backend/src/api/controllers/SegmentController.ts b/packages/backend/src/api/controllers/SegmentController.ts index a29de65d0..ea5b165b3 100644 --- a/packages/backend/src/api/controllers/SegmentController.ts +++ b/packages/backend/src/api/controllers/SegmentController.ts @@ -3,7 +3,6 @@ import { BatchDeleteResult } from 'upgrade_types'; import { Inject } from 'typedi'; import { BatchDeleteService } from '../services/batch/BatchDeleteService'; import { BatchEntityIdsValidator } from './validators/BatchEntityIdsValidator'; -import { DeletionStateService } from '../services/DeletionStateService'; import { JsonController, CurrentUser, @@ -65,10 +64,10 @@ interface SegmentPaginationInfo extends PaginationResponse { * format: uuid * outcome: * type: string - * enum: [deleted, not_found, ineligible, failed, unknown, not_attempted] + * enum: [deleted, not_found, failed, unknown, not_attempted] * reasonCode: * type: string - * enum: [not_found, missing_permission, experiment_state_unsupported, feature_flag_enabled, feature_flag_status_unsupported, segment_in_use, protected_segment_type, eligibility_unavailable, delete_failed, lock_timeout, external_sync_failed, outcome_unknown, post_delete_failed, batch_budget_exceeded] + * enum: [not_found, delete_failed, lock_timeout, external_sync_failed, outcome_unknown, post_delete_failed, batch_budget_exceeded] * BatchDeleteResult: * type: object * required: [phase, results] @@ -277,8 +276,7 @@ interface SegmentPaginationInfo extends PaginationResponse { export class SegmentController { constructor( public segmentService: SegmentService, - @Inject(() => BatchDeleteService) private batchDeleteService: BatchDeleteService, - private deletionStateService: DeletionStateService + @Inject(() => BatchDeleteService) private batchDeleteService: BatchDeleteService ) {} /** @@ -286,7 +284,7 @@ export class SegmentController { * /segments/batch-delete: * post: * summary: Delete the selected segments - * description: Checks the selection, skips missing or ineligible items, and deletes eligible items sequentially. Other execution failures stop the remaining items. Returns one result per ID. + * description: Deletes selected segments sequentially using the existing deletion workflow, skipping missing items. Execution failures stop the remaining items. Returns one result per ID. * tags: * - Segment * parameters: @@ -674,21 +672,14 @@ export class SegmentController { * '500': * description: Internal Server Error * '400': - * description: Invalid UUID, segment is in use, or segment is not an ordinary public segment - * '404': - * description: Segment not found + * description: Invalid UUID */ @Delete('/:segmentId') public deleteSegment( @Params({ validate: true }) { segmentId }: SegmentIdValidator, @Req() request: AppRequest ): Promise { - return this.segmentService.deleteSegment( - segmentId, - request.logger, - undefined, - this.deletionStateService.transactionFor('segments', segmentId) - ); + return this.segmentService.deleteSegment(segmentId, request.logger); } /** diff --git a/packages/backend/src/api/repositories/DeletionRepository.ts b/packages/backend/src/api/repositories/DeletionRepository.ts index ca7343cec..636f8cfa4 100644 --- a/packages/backend/src/api/repositories/DeletionRepository.ts +++ b/packages/backend/src/api/repositories/DeletionRepository.ts @@ -1,5 +1,5 @@ import { EntityManager, Repository } from 'typeorm'; -import { EXPERIMENT_STATE, STANDARD_LIST_TYPE } from 'upgrade_types'; +import { BatchDeleteEntity } from 'upgrade_types'; import { EntityRepository } from '../../typeorm-typedi-extensions'; import { Experiment } from '../models/Experiment'; import { FeatureFlag } from '../models/FeatureFlag'; @@ -9,13 +9,6 @@ import repositoryError from './utils/repositoryError'; /** All reads and locks use the caller's deletion transaction, never the repository's default manager. */ @EntityRepository() export class DeletionRepository extends Repository { - public async getTransactionIsolation(manager: EntityManager): Promise { - const [{ transaction_isolation }] = await manager.query('SHOW transaction_isolation').catch((errorMsg: any) => { - throw repositoryError('DeletionRepository', 'getTransactionIsolation', {}, errorMsg); - }); - return transaction_isolation; - } - public async setLockTimeout(manager: EntityManager): Promise { // Cap each lock wait through cleanup/deletion; preserve any tighter caller/database limit. // Transaction-local settings expire on commit/rollback. This is not a total request deadline. @@ -31,98 +24,17 @@ export class DeletionRepository extends Repository { }); } - public async findDeletionState( - entity: 'experiments' | 'flags', + public async findForDeletion( + entity: BatchDeleteEntity, id: string, manager: EntityManager - ): Promise | Pick> { - const query = - entity === 'experiments' - ? manager - .getRepository(Experiment) - .findOne({ where: { id }, select: { id: true, state: true }, lock: { mode: 'pessimistic_write' } }) - : manager - .getRepository(FeatureFlag) - .findOne({ where: { id }, select: { id: true, status: true }, lock: { mode: 'pessimistic_write' } }); - return query.catch((errorMsg: any) => { - throw repositoryError('DeletionRepository', 'findDeletionState', { entity, id }, errorMsg); - }); - } - - public async findSegmentForDeletion(id: string, manager: EntityManager): Promise> { - // FOR UPDATE conflicts with the KEY SHARE locks taken by new foreign-key references. - const [target] = await manager - .query('SELECT id, type FROM segment WHERE id = $1 FOR UPDATE', [id]) + ): Promise<{ id: string } | null> { + const target = { experiments: Experiment, flags: FeatureFlag, segments: Segment }[entity]; + return manager + .getRepository<{ id: string }>(target) + .findOne({ where: { id }, select: { id: true }, lock: { mode: 'pessimistic_write' } }) .catch((errorMsg: any) => { - throw repositoryError('DeletionRepository', 'findSegmentForDeletion', { id }, errorMsg); + throw repositoryError('DeletionRepository', 'findForDeletion', { entity, id }, errorMsg); }); - return target; - } - - /** Call after locking the target with findSegmentForDeletion, in the same READ COMMITTED transaction. */ - public async lockReferencesAndCheckSegmentUsage(id: string, manager: EntityManager): Promise { - try { - const locked = new Set([id]); - let ancestors: string[]; - let unlocked: string[]; - do { - const rows: { id: string }[] = await manager.query( - `WITH RECURSIVE ancestors(id) AS ( - SELECT $1::uuid - UNION - SELECT link."parentSegmentId" - FROM segment_for_segment link - JOIN ancestors ON link."childSegmentId" = ancestors.id - ) - SELECT id FROM ancestors ORDER BY id`, - [id] - ); - ancestors = rows.map((row) => row.id); - unlocked = ancestors.filter((ancestor) => !locked.has(ancestor)); - if (unlocked.length) { - const acquired: { id: string }[] = await manager.query( - 'SELECT id FROM segment WHERE id = ANY($1::uuid[]) ORDER BY id FOR UPDATE', - [unlocked] - ); - acquired.forEach((row) => locked.add(row.id)); - } - // A writer may have committed while a lock was being acquired. Discover again with a fresh snapshot. - } while (unlocked.length); - - // Archived owners must also be locked: reactivation can make an otherwise unused segment Used. - // KEY SHARE would not stop a state-only UPDATE; SHARE does. - await manager.query( - `SELECT experiment.id FROM experiment WHERE experiment.id IN ( - SELECT "experimentId" FROM experiment_segment_inclusion WHERE "segmentId" = ANY($1::uuid[]) - UNION - SELECT "experimentId" FROM experiment_segment_exclusion WHERE "segmentId" = ANY($1::uuid[]) - ) ORDER BY experiment.id FOR SHARE`, - [ancestors] - ); - - const [{ used }] = await manager.query( - `SELECT - EXISTS ( - SELECT 1 FROM experiment_segment_inclusion ref JOIN experiment ON experiment.id = ref."experimentId" - WHERE ref."segmentId" = ANY($2::uuid[]) AND experiment.state <> $3 - ) OR EXISTS ( - SELECT 1 FROM experiment_segment_exclusion ref JOIN experiment ON experiment.id = ref."experimentId" - WHERE ref."segmentId" = ANY($2::uuid[]) AND experiment.state <> $3 - ) OR EXISTS ( - SELECT 1 FROM feature_flag_segment_inclusion WHERE "segmentId" = ANY($2::uuid[]) - ) OR EXISTS ( - SELECT 1 FROM feature_flag_segment_exclusion WHERE "segmentId" = ANY($2::uuid[]) - ) OR EXISTS ( - SELECT 1 FROM segment_for_segment child - JOIN segment list ON list.id = child."parentSegmentId" - WHERE child."childSegmentId" = $1 AND list."listType" = $4 - AND EXISTS (SELECT 1 FROM segment_for_segment parent WHERE parent."childSegmentId" = list.id) - ) AS used`, - [id, ancestors, EXPERIMENT_STATE.ARCHIVED, STANDARD_LIST_TYPE.SEGMENT] - ); - return used; - } catch (errorMsg) { - throw repositoryError('DeletionRepository', 'lockReferencesAndCheckSegmentUsage', { id }, errorMsg); - } } } diff --git a/packages/backend/src/api/repositories/ExperimentRepository.ts b/packages/backend/src/api/repositories/ExperimentRepository.ts index 3c106968b..f1cfd1dcc 100644 --- a/packages/backend/src/api/repositories/ExperimentRepository.ts +++ b/packages/backend/src/api/repositories/ExperimentRepository.ts @@ -1,5 +1,5 @@ import { EXPERIMENT_STATE, SERVER_ERROR } from 'upgrade_types'; -import { Repository, EntityManager, Brackets, SelectQueryBuilder, In } from 'typeorm'; +import { Repository, EntityManager, Brackets, SelectQueryBuilder } from 'typeorm'; import { EntityRepository } from '../../typeorm-typedi-extensions'; import { Experiment } from '../models/Experiment'; import repositoryError from './utils/repositoryError'; @@ -121,16 +121,6 @@ export class ExperimentRepository extends Repository { }); } - public async findForDeletionEligibility(ids: string[]): Promise>> { - return this.find({ - where: { id: In(ids) }, - select: { id: true, state: true }, - }).catch((errorMsg: any) => { - const errorMsgString = repositoryError('ExperimentRepository', 'findForDeletionEligibility', { ids }, errorMsg); - throw errorMsgString; - }); - } - public async findContextByExperimentId(id: string): Promise { const experiment = await this.createQueryBuilder('experiment') .select(['experiment.id', 'experiment.context']) diff --git a/packages/backend/src/api/repositories/FeatureFlagRepository.ts b/packages/backend/src/api/repositories/FeatureFlagRepository.ts index 2f0f0f3b2..c6a1ea1f2 100644 --- a/packages/backend/src/api/repositories/FeatureFlagRepository.ts +++ b/packages/backend/src/api/repositories/FeatureFlagRepository.ts @@ -1,4 +1,4 @@ -import { Repository, EntityManager, SelectQueryBuilder, In } from 'typeorm'; +import { Repository, EntityManager, SelectQueryBuilder } from 'typeorm'; import { EntityRepository } from '../../typeorm-typedi-extensions'; import { FeatureFlag } from '../models/FeatureFlag'; import repositoryError from './utils/repositoryError'; @@ -150,16 +150,6 @@ export class FeatureFlagRepository extends Repository { return result; } - public async findForDeletionEligibility(ids: string[]): Promise>> { - return this.find({ - where: { id: In(ids) }, - select: { id: true, status: true }, - }).catch((errorMsg: any) => { - const errorMsgString = repositoryError('FeatureFlagRepository', 'findForDeletionEligibility', { ids }, errorMsg); - throw errorMsgString; - }); - } - public async validateUniqueKey(flagDTO: FeatureFlagValidation) { const queryBuilder = this.createQueryBuilder('feature_flag') .where('feature_flag.key = :key', { key: flagDTO.key }) diff --git a/packages/backend/src/api/repositories/SegmentRepository.ts b/packages/backend/src/api/repositories/SegmentRepository.ts index 228938190..2759e17ef 100644 --- a/packages/backend/src/api/repositories/SegmentRepository.ts +++ b/packages/backend/src/api/repositories/SegmentRepository.ts @@ -30,18 +30,6 @@ export class SegmentRepository extends Repository { }); } - public async findForDeletionEligibility(ids: string[]): Promise { - return this.createQueryBuilder('segment') - .leftJoin('segment.subSegments', 'child') - .select(['segment.id', 'segment.type', 'child.id']) - .where('segment.id IN (:...ids)', { ids }) - .getMany() - .catch((errorMsg: any) => { - const errorMsgString = repositoryError('segmentRepository', 'findForDeletionEligibility', { ids }, errorMsg); - throw errorMsgString; - }); - } - /** * Retrieves all segments of a specified type from the database. * diff --git a/packages/backend/src/api/services/DeletionStateService.ts b/packages/backend/src/api/services/DeletionStateService.ts deleted file mode 100644 index 63669ac30..000000000 --- a/packages/backend/src/api/services/DeletionStateService.ts +++ /dev/null @@ -1,94 +0,0 @@ -import { Service } from 'typedi'; -import { DataSource, EntityManager } from 'typeorm'; -import { HttpError } from 'routing-controllers'; -import { - BatchDeleteEntity, - BatchDeleteItemResult, - DeletionReasonCode, - getExperimentDeletionReason, - getFlagDeletionReason, -} from 'upgrade_types'; -import { InjectDataSource, InjectRepository } from '../../typeorm-typedi-extensions'; -import { DeletionTransaction } from '../../types/DeletionTransaction'; -import { Experiment } from '../models/Experiment'; -import { FeatureFlag } from '../models/FeatureFlag'; -import { DeletionRepository } from '../repositories/DeletionRepository'; -import { assertSegmentDeletionAllowed, SegmentDeletionBlockedError } from './batch/SegmentDeletionGuard'; - -export class DeletionBlockedError extends Error { - constructor(public readonly result: BatchDeleteItemResult) { - super(result.reasonCode); - } -} - -/** Check state under the same locks/transaction as deletion; permissions remain the caller's responsibility. */ -export async function assertDeletionStateAllowed( - entity: BatchDeleteEntity, - id: string, - manager: EntityManager, - repository: DeletionRepository -): Promise { - if (entity === 'segments') { - try { - await assertSegmentDeletionAllowed(id, manager, repository); - } catch (error) { - if (!(error instanceof SegmentDeletionBlockedError)) throw error; - throw new DeletionBlockedError({ - id, - outcome: error.reason === 'missing' ? 'not_found' : 'ineligible', - reasonCode: - error.reason === 'missing' - ? DeletionReasonCode.NOT_FOUND - : error.reason === 'protected' - ? DeletionReasonCode.PROTECTED_SEGMENT_TYPE - : DeletionReasonCode.SEGMENT_IN_USE, - }); - } - return; - } - - await repository.setLockTimeout(manager); - const row = await repository.findDeletionState(entity, id, manager); - if (!row) throw new DeletionBlockedError({ id, outcome: 'not_found', reasonCode: DeletionReasonCode.NOT_FOUND }); - const reason = - entity === 'experiments' - ? getExperimentDeletionReason((row as Experiment).state) - : getFlagDeletionReason((row as FeatureFlag).status); - if (reason) throw new DeletionBlockedError({ id, outcome: 'ineligible', reasonCode: reason }); -} - -const rejectionMessages: Partial> = { - [DeletionReasonCode.NOT_FOUND]: 'The item no longer exists.', - [DeletionReasonCode.EXPERIMENT_STATE_UNSUPPORTED]: 'The experiment cannot be deleted in its current state.', - [DeletionReasonCode.FEATURE_FLAG_ENABLED]: 'Disable the feature flag before deleting it.', - [DeletionReasonCode.FEATURE_FLAG_STATUS_UNSUPPORTED]: 'The feature flag cannot be deleted in its current state.', - [DeletionReasonCode.SEGMENT_IN_USE]: 'The segment is in use and cannot be deleted.', - [DeletionReasonCode.PROTECTED_SEGMENT_TYPE]: 'Only ordinary public segments can be deleted through this endpoint.', -}; - -/** Opt in at the three single-item API boundaries, not in services also used for internal list cleanup. */ -@Service() -export class DeletionStateService { - constructor( - @InjectDataSource() private dataSource: DataSource, - @InjectRepository() private deletionRepository: DeletionRepository - ) {} - - transactionFor(entity: BatchDeleteEntity, id: string): DeletionTransaction { - return async (work) => { - try { - return await this.dataSource.transaction('READ COMMITTED', async (manager) => { - await assertDeletionStateAllowed(entity, id, manager, this.deletionRepository); - return work(manager); - }); - } catch (error) { - if (!(error instanceof DeletionBlockedError)) throw error; - // A state rejection is not a duplicate-key conflict (the existing middleware reserves 409 for that). - throw new HttpError( - error.result.outcome === 'not_found' ? 404 : 400, - rejectionMessages[error.result.reasonCode] || 'The item cannot be deleted in its current state.' - ); - } - }; - } -} diff --git a/packages/backend/src/api/services/SegmentService.ts b/packages/backend/src/api/services/SegmentService.ts index eb43e3801..4f00c26e9 100644 --- a/packages/backend/src/api/services/SegmentService.ts +++ b/packages/backend/src/api/services/SegmentService.ts @@ -539,7 +539,6 @@ export class SegmentService { public async deleteSegment( id: string, logger: UpgradeLogger, - beforeDelete?: (manager: EntityManager) => Promise, executeTransaction?: DeletionTransaction ): Promise { logger.info({ message: `Delete segment by id. segmentId: ${id}` }); @@ -556,10 +555,9 @@ export class SegmentService { logger, () => this.featureFlagPrecomputedSegmentService.getAffectedFlagIds(id), () => - transaction(async (transactionalEntityManager) => { - await beforeDelete?.(transactionalEntityManager); - return this.deleteSegmentAndPrivateSubsegments(id, logger, transactionalEntityManager); - }) + transaction((transactionalEntityManager) => + this.deleteSegmentAndPrivateSubsegments(id, logger, transactionalEntityManager) + ) ); this.experimentPrecomputedSegmentService.scheduleRecomputeForExperiments(affectedExperimentIds, logger); diff --git a/packages/backend/src/api/services/batch/BatchDeleteService.ts b/packages/backend/src/api/services/batch/BatchDeleteService.ts index a4268caba..01f03750f 100644 --- a/packages/backend/src/api/services/batch/BatchDeleteService.ts +++ b/packages/backend/src/api/services/batch/BatchDeleteService.ts @@ -19,23 +19,22 @@ import { ExperimentService } from '../ExperimentService'; import { FeatureFlagService } from '../FeatureFlagService'; import { MoocletExperimentService } from '../MoocletExperimentService'; import { SegmentService } from '../SegmentService'; -import { - DeletionEligibilityService, - DeletionEligibilityItem, - DeletionEligibilityResult, -} from './DeletionEligibilityService'; -import { assertDeletionStateAllowed, DeletionBlockedError } from '../DeletionStateService'; import { DeletionRepository } from '../../repositories/DeletionRepository'; // Admission budget: never abandon an in-flight deletion or claim it has been cancelled. -// Check before starting another item and again after acquiring its eligibility locks. +// Check before starting another item and again after acquiring its target lock. export const BATCH_DELETE_START_BUDGET_MS = 60_000; +class BatchDeleteSkippedError extends Error { + constructor(public readonly result: BatchDeleteItemResult) { + super(result.reasonCode); + } +} + @Service() export class BatchDeleteService { constructor( @InjectDataSource() private dataSource: DataSource, - @Inject(() => DeletionEligibilityService) private eligibility: DeletionEligibilityService, @Inject(() => ExperimentService) private experiments: ExperimentService, @Inject(() => FeatureFlagService) private flags: FeatureFlagService, @Inject(() => SegmentService) private segments: SegmentService, @@ -52,29 +51,9 @@ export class BatchDeleteService { if (!user) throw new UnauthorizedError('A current user is required'); if (!hasBatchDeletePermission(user.role, entity)) throw new ForbiddenError('Deletion permission is required'); const deadline = performance.now() + BATCH_DELETE_START_BUDGET_MS; - let preflight: DeletionEligibilityResult; - try { - preflight = await this.eligibility[entity](ids); - } catch (error) { - logger.error({ message: 'Batch deletion preflight failed', entity, error }); - return { - phase: 'rejected', - results: ids.map((id) => ({ - id, - outcome: 'not_attempted', - reasonCode: DeletionReasonCode.ELIGIBILITY_UNAVAILABLE, - })), - }; - } - const eligibilityById = new Map(preflight.items.map((item) => [item.id, item])); const results: BatchDeleteItemResult[] = []; let phase: BatchDeleteResult['phase'] = 'rejected'; for (const id of ids) { - const eligibility = eligibilityById.get(id); - if (!eligibility.canDelete) { - results.push(this.preflightResult(eligibility)); - continue; - } if (performance.now() >= deadline) { results.push( ...ids.slice(results.length).map( @@ -87,11 +66,11 @@ export class BatchDeleteService { ); break; } - phase = 'executed'; const result = await this.deleteOne(entity, id, user, logger, deadline); results.push(result); - // A changed state or an already absent target does not prevent independent items from being deleted. - if (result.outcome === 'ineligible' || result.outcome === 'not_found') continue; + if (result.outcome !== 'not_found' && result.outcome !== 'not_attempted') phase = 'executed'; + // An already absent target does not prevent independent items from being deleted. + if (result.outcome === 'not_found') continue; // A committed item with a post-delete failure must not be retried, but still stops this batch. if (result.outcome !== 'deleted' || result.reasonCode) { results.push( @@ -108,11 +87,6 @@ export class BatchDeleteService { return { phase, results }; } - private preflightResult(item: DeletionEligibilityItem): BatchDeleteItemResult { - const outcome = item.availability === 'not_found' ? 'not_found' : 'ineligible'; - return { id: item.id, outcome, reasonCode: item.reasonCode || DeletionReasonCode.ELIGIBILITY_UNAVAILABLE }; - } - private async deleteOne( entity: BatchDeleteEntity, id: string, @@ -131,11 +105,13 @@ export class BatchDeleteService { try { await runner.connect(); await runner.startTransaction('READ COMMITTED'); - // Segments use the existing beforeDelete hook inside this transaction. - if (entity !== 'segments') - await assertDeletionStateAllowed(entity, id, runner.manager, this.deletionRepository); + await this.deletionRepository.setLockTimeout(runner.manager); + const target = await this.deletionRepository.findForDeletion(entity, id, runner.manager); + if (!target) { + throw new BatchDeleteSkippedError({ id, outcome: 'not_found', reasonCode: DeletionReasonCode.NOT_FOUND }); + } if (performance.now() >= deadline) { - throw new DeletionBlockedError({ + throw new BatchDeleteSkippedError({ id, outcome: 'not_attempted', reasonCode: DeletionReasonCode.BATCH_BUDGET_EXCEEDED, @@ -146,7 +122,7 @@ export class BatchDeleteService { // Legacy experiment/flag repositories return arrays despite their declared return types. const deleted = Array.isArray(response) ? response[0] : response; if (!deleted || (deleted as { id?: string }).id?.toLowerCase() !== id.toLowerCase()) { - throw new Error('Deletion did not return the guarded target'); + throw new Error('Deletion did not return the requested target'); } commitAttempted = true; await runner.commitTransaction(); @@ -187,21 +163,7 @@ export class BatchDeleteService { } else if (entity === 'flags') { await this.flags.delete(id, user, logger, executeTransaction); } else { - await this.segments.deleteSegment( - id, - logger, - async (manager) => { - await assertDeletionStateAllowed(entity, id, manager, this.deletionRepository); - if (performance.now() >= deadline) { - throw new DeletionBlockedError({ - id, - outcome: 'not_attempted', - reasonCode: DeletionReasonCode.BATCH_BUDGET_EXCEEDED, - }); - } - }, - executeTransaction - ); + await this.segments.deleteSegment(id, logger, executeTransaction); } return committed ? { id, outcome: 'deleted' } @@ -213,7 +175,7 @@ export class BatchDeleteService { if (commitAttempted || (mutationStarted && !rolledBack)) { return { id, outcome: 'unknown', reasonCode: DeletionReasonCode.OUTCOME_UNKNOWN }; } - if (error instanceof DeletionBlockedError && rolledBack) return error.result; + if (error instanceof BatchDeleteSkippedError && rolledBack) return error.result; return { id, outcome: 'failed', diff --git a/packages/backend/src/api/services/batch/DeletionEligibilityService.ts b/packages/backend/src/api/services/batch/DeletionEligibilityService.ts deleted file mode 100644 index b3a16272e..000000000 --- a/packages/backend/src/api/services/batch/DeletionEligibilityService.ts +++ /dev/null @@ -1,95 +0,0 @@ -import { Inject, Service } from 'typedi'; -import { - DeletionReasonCode, - getExperimentDeletionReason, - getFlagDeletionReason, - SEGMENT_STATUS, - SEGMENT_TYPE, -} from 'upgrade_types'; -import { InjectRepository } from '../../../typeorm-typedi-extensions'; -import { ExperimentRepository } from '../../repositories/ExperimentRepository'; -import { FeatureFlagRepository } from '../../repositories/FeatureFlagRepository'; -import { SegmentRepository } from '../../repositories/SegmentRepository'; -import { SegmentService, SegmentWithStatus } from '../SegmentService'; - -export interface DeletionEligibilityItem { - id: string; - availability: 'present' | 'not_found' | 'unavailable'; - canDelete: boolean; - reasonCode?: DeletionReasonCode; -} - -export interface DeletionEligibilityResult { - items: DeletionEligibilityItem[]; -} - -type EligibilitySummary = Omit; - -/** Internal preflight for batch deletion. The caller checks permissions before querying. */ -@Service() -export class DeletionEligibilityService { - constructor( - @InjectRepository() private experimentRepository: ExperimentRepository, - @InjectRepository() private featureFlagRepository: FeatureFlagRepository, - @Inject(() => SegmentService) private segmentService: SegmentService, - @InjectRepository() private segmentRepository: SegmentRepository - ) {} - - public async experiments(ids: string[]): Promise { - const rows = await this.experimentRepository.findForDeletionEligibility(ids); - return this.result(ids, rows, (row) => ({ - availability: 'present', - reasonCode: getExperimentDeletionReason(row.state), - })); - } - - public async flags(ids: string[]): Promise { - const rows = await this.featureFlagRepository.findForDeletionEligibility(ids); - return this.result(ids, rows, (row) => ({ - availability: 'present', - reasonCode: getFlagDeletionReason(row.status), - })); - } - - public async segments(ids: string[]): Promise { - const rows = await this.segmentRepository.findForDeletionEligibility(ids); - const publicRows = rows.filter((row) => row.type === SEGMENT_TYPE.PUBLIC); - // One evaluation for the entire selection; no per-ID detail/member loads or mutation locks. - const statuses = publicRows.length - ? ((await this.segmentService.getSegmentStatus(publicRows)).segmentsData as SegmentWithStatus[]) - : []; - const statusById = new Map(statuses.map((row) => [row.id, row.status])); - return this.result(ids, rows, (row) => { - if (row.type !== SEGMENT_TYPE.PUBLIC) { - return { availability: 'present', reasonCode: DeletionReasonCode.PROTECTED_SEGMENT_TYPE }; - } - const status = statusById.get(row.id); - return { - availability: status ? 'present' : 'unavailable', - reasonCode: - status === SEGMENT_STATUS.UNUSED - ? undefined - : status === SEGMENT_STATUS.USED - ? DeletionReasonCode.SEGMENT_IN_USE - : DeletionReasonCode.ELIGIBILITY_UNAVAILABLE, - }; - }); - } - - private result( - ids: string[], - rows: T[], - summarize: (row: T) => EligibilitySummary - ): DeletionEligibilityResult { - const byId = new Map(rows.map((row) => [row.id.toLowerCase(), row])); - const items = ids.map((id): DeletionEligibilityItem => { - const row = byId.get(id.toLowerCase()); - if (!row) { - return { id, availability: 'not_found', canDelete: false, reasonCode: DeletionReasonCode.NOT_FOUND }; - } - const summary = summarize(row); - return { id, ...summary, canDelete: summary.availability === 'present' && !summary.reasonCode }; - }); - return { items }; - } -} diff --git a/packages/backend/src/api/services/batch/SegmentDeletionGuard.ts b/packages/backend/src/api/services/batch/SegmentDeletionGuard.ts deleted file mode 100644 index 8827c3d4c..000000000 --- a/packages/backend/src/api/services/batch/SegmentDeletionGuard.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { EntityManager } from 'typeorm'; -import { SEGMENT_TYPE } from 'upgrade_types'; -import { DeletionRepository } from '../../repositories/DeletionRepository'; - -export class SegmentDeletionBlockedError extends Error { - constructor(public readonly segmentId: string, public readonly reason: 'missing' | 'protected' | 'used') { - super(`Segment ${segmentId} cannot be deleted: ${reason}`); - this.name = 'SegmentDeletionBlockedError'; - } -} - -/** Guard for ordinary public-segment deletion. Call inside the transaction that will delete this segment. */ -export async function assertSegmentDeletionAllowed( - id: string, - manager: EntityManager, - repository: DeletionRepository -): Promise { - if (!manager.queryRunner?.isTransactionActive) { - throw new Error('Segment deletion guard requires an active transaction'); - } - const isolation = await repository.getTransactionIsolation(manager); - if (isolation !== 'read committed') { - throw new Error('Segment deletion guard requires READ COMMITTED isolation'); - } - - await repository.setLockTimeout(manager); - const target = await repository.findSegmentForDeletion(id, manager); - if (!target) { - throw new SegmentDeletionBlockedError(id, 'missing'); - } - if (target.type !== SEGMENT_TYPE.PUBLIC) { - throw new SegmentDeletionBlockedError(id, 'protected'); - } - - const used = await repository.lockReferencesAndCheckSegmentUsage(id, manager); - if (used) { - throw new SegmentDeletionBlockedError(id, 'used'); - } -} diff --git a/packages/backend/test/integration/BatchDelete/index.ts b/packages/backend/test/integration/BatchDelete/index.ts index 999a30aa3..999032fcd 100644 --- a/packages/backend/test/integration/BatchDelete/index.ts +++ b/packages/backend/test/integration/BatchDelete/index.ts @@ -10,6 +10,7 @@ import { CACHE_PREFIX, DeletionReasonCode, EXPERIMENT_STATE, + EXPERIMENT_STATE_INTERNAL_NAME_OVERRIDES, FEATURE_FLAG_STATUS, LOG_TYPE, POST_EXPERIMENT_RULE, @@ -38,7 +39,6 @@ import { FeatureFlagPrecomputedSegmentService } from '../../../src/api/services/ import { MoocletExperimentService } from '../../../src/api/services/MoocletExperimentService'; import { SegmentService } from '../../../src/api/services/SegmentService'; import { BatchDeleteService } from '../../../src/api/services/batch/BatchDeleteService'; -import { DeletionEligibilityService } from '../../../src/api/services/batch/DeletionEligibilityService'; import { currentUserChecker } from '../../../src/auth/currentUserChecker'; import { env } from '../../../src/env'; import { iocLoader } from '../../../src/loaders/iocLoader'; @@ -204,101 +204,6 @@ export function registerBatchDeleteTests(connections: () => [DataSource, DataSou expect(await db.getRepository(model[entity]).countBy({ id: row.id })).toBe(0); }); - test('skips a hidden Used segment and deletes an eligible selection', async () => { - const [unused, child, parent] = await create('segments', 3); - await ownedList('segments', parent.id, child.id); - const [experiment] = await create('experiments'); - await ownedList('experiments', experiment.id, parent.id); - const { body } = await request(app) - .post(route('segments')) - .send({ - ids: [unused.id, child.id], - searchParams: { key: 'name', string: unused.name }, - }) - .expect(200); - expect(body.phase).toBe('executed'); - expect(body.results).toEqual([ - { id: unused.id, outcome: 'deleted' }, - { id: child.id, outcome: 'ineligible', reasonCode: DeletionReasonCode.SEGMENT_IN_USE }, - ]); - expect(await db.getRepository(Segment).countBy({ id: unused.id })).toBe(0); - expect(await db.getRepository(Segment).countBy({ id: child.id })).toBe(1); - }); - - test.each(entities)('%s rechecks eligibility after preflight using the mutation transaction', async (entity) => { - const [before, row, after] = await create(entity, 3); - const service = Container.get(DeletionEligibilityService); - const original = service[entity].bind(service); - jest.spyOn(service, entity).mockImplementationOnce(async (ids) => { - const result = await original(ids); - // A separate connection commits a normal edit after the bulk read, before deletion begins. - if (entity === 'experiments') - await writer.getRepository(Experiment).update(row.id, { state: EXPERIMENT_STATE.DRAFT }); - else if (entity === 'flags') - await writer.getRepository(FeatureFlag).update(row.id, { status: FEATURE_FLAG_STATUS.ENABLED }); - else { - const [flag] = await create('flags'); - await ownedList('flags', flag.id, row.id, writer); - } - return result; - }); - const { body } = await request(app) - .post(route(entity)) - .send({ ids: [before.id, row.id, after.id] }) - .expect(200); - expect(body).toEqual({ - phase: 'executed', - results: [ - { id: before.id, outcome: 'deleted' }, - { - id: row.id, - outcome: 'ineligible', - reasonCode: - entity === 'experiments' - ? DeletionReasonCode.EXPERIMENT_STATE_UNSUPPORTED - : entity === 'flags' - ? DeletionReasonCode.FEATURE_FLAG_ENABLED - : DeletionReasonCode.SEGMENT_IN_USE, - }, - { id: after.id, outcome: 'deleted' }, - ], - }); - expect(await db.getRepository(model[entity]).countBy({ id: row.id })).toBe(1); - expect(await db.getRepository(model[entity]).countBy({ id: In([before.id, after.id]) })).toBe(0); - }); - - test.each(entities.flatMap((entity) => [0, 1, 2].map((blockedIndex) => ({ entity, blockedIndex }))))( - '$entity skips an ineligible selection at position $blockedIndex and deletes the other two', - async ({ entity, blockedIndex }) => { - const rows = await create(entity, 3); - const blocked = rows[blockedIndex]; - const lists = []; - for (const row of rows) lists.push(await ownedList(entity, row.id)); - if (entity === 'experiments') - await writer.getRepository(Experiment).update(blocked.id, { state: EXPERIMENT_STATE.DRAFT }); - else if (entity === 'flags') - await writer.getRepository(FeatureFlag).update(blocked.id, { status: FEATURE_FLAG_STATUS.ENABLED }); - else { - const [flag] = await create('flags'); - await ownedList('flags', flag.id, blocked.id, writer); - } - const { body } = await request(app) - .post(route(entity)) - .send({ ids: rows.map((row) => row.id) }) - .expect(200); - expect(body.phase).toBe('executed'); - expect(body.results.map((item) => ({ id: item.id, outcome: item.outcome }))).toEqual( - rows.map((row, index) => ({ id: row.id, outcome: index === blockedIndex ? 'ineligible' : 'deleted' })) - ); - for (const [index, row] of rows.entries()) { - const remaining = index === blockedIndex ? 1 : 0; - expect(await db.getRepository(model[entity]).countBy({ id: row.id })).toBe(remaining); - expect(await db.getRepository(Segment).countBy({ id: lists[index].id })).toBe(remaining); - expect(await db.getRepository(IndividualForSegment).countBy({ segmentId: lists[index].id })).toBe(remaining); - } - } - ); - test.each(['cleanup', 'commit'])('rolls back flag deletion when %s fails', async (failureStage) => { const rows = await create('flags', 3); const list = await ownedList('flags', rows[1].id); @@ -372,7 +277,7 @@ export function registerBatchDeleteTests(connections: () => [DataSource, DataSou } }); - test('bounds a guard lock wait and reports a confirmed failure without deleting', async () => { + test('bounds a target lock wait and reports a confirmed failure without deleting', async () => { const [row] = await create('flags'); const blocker = writer.createQueryRunner(); await blocker.connect(); @@ -438,6 +343,7 @@ export function registerBatchDeleteTests(connections: () => [DataSource, DataSou async (success) => { env.mooclets.enabled = true; const [row] = await create('experiments'); + await db.getRepository(Experiment).update(row.id, { state: EXPERIMENT_STATE.DRAFT }); const list = await ownedList('experiments', row.id); const service = Container.get(MoocletExperimentService); const ref = { id: randomUUID() } as MoocletExperimentRef; @@ -484,102 +390,82 @@ export function registerBatchDeleteTests(connections: () => [DataSource, DataSou } ); - test.each(entities)('%s single-delete rejects an ineligible target before removing owned lists', async (entity) => { - const [row] = await create(entity); - const list = await ownedList(entity, row.id); - if (entity === 'experiments') - await db.getRepository(Experiment).update(row.id, { state: EXPERIMENT_STATE.DRAFT }); - else if (entity === 'flags') - await db.getRepository(FeatureFlag).update(row.id, { status: FEATURE_FLAG_STATUS.ENABLED }); - else { - const [flag] = await create('flags'); - await ownedList('flags', flag.id, row.id); + test.each(['single', 'batch'])('experiment %s deletion allows every experiment state', async (mode) => { + const states = [ + ...new Set( + Object.values(EXPERIMENT_STATE).map((state) => EXPERIMENT_STATE_INTERNAL_NAME_OVERRIDES[state] || state) + ), + ]; + const rows = await create('experiments', states.length); + for (const [index, row] of rows.entries()) { + await db.getRepository(Experiment).update(row.id, { state: states[index] }); } - const messages = { - experiments: 'The experiment cannot be deleted in its current state.', - flags: 'Disable the feature flag before deleting it.', - segments: 'The segment is in use and cannot be deleted.', - }; - const { body } = await request(app).delete(`/api/${entity}/${row.id}`).expect(400); - expect(body.message).toBe(messages[entity]); - expect(await db.getRepository(model[entity]).countBy({ id: row.id })).toBe(1); - expect(await db.getRepository(Segment).countBy({ id: list.id })).toBe(1); - expect(await db.getRepository(IndividualForSegment).countBy({ segmentId: list.id })).toBe(1); - }); - - test.each(entities)('%s single-delete reports a missing target as 404', async (entity) => { - const { body } = await request(app).delete(`/api/${entity}/${randomUUID()}`).expect(404); - expect(body.message).toBe('The item no longer exists.'); + if (mode === 'single') { + for (const row of rows) await request(app).delete(`/api/experiments/${row.id}`).expect(200); + } else { + const { body } = await request(app) + .post(route('experiments')) + .send({ ids: rows.map(({ id }) => id) }) + .expect(200); + expect(body.results).toEqual(rows.map(({ id }) => ({ id, outcome: 'deleted' }))); + } + expect(await db.getRepository(Experiment).countBy({ id: In(rows.map(({ id }) => id)) })).toBe(0); + expect(await db.getRepository(ExperimentAuditLog).countBy({ type: LOG_TYPE.EXPERIMENT_DELETED })).toBe( + rows.length + ); }); - test.each(['experiments', 'flags'] as const)( - '%s single-delete waits for a concurrent state edit and rejects its committed state', - async (entity) => { + test.each(['single', 'batch'])('%s deletion allows Enabled flags and Used segments', async (mode) => { + for (const entity of ['flags', 'segments'] as const) { const [row] = await create(entity); - const blocker = writer.createQueryRunner(); - await blocker.connect(); - await blocker.startTransaction(); - let pending: Promise; - try { - if (entity === 'experiments') - await blocker.manager.getRepository(Experiment).update(row.id, { state: EXPERIMENT_STATE.DRAFT }); - else await blocker.manager.getRepository(FeatureFlag).update(row.id, { status: FEATURE_FLAG_STATUS.ENABLED }); - const [{ pid: writerPid }] = await blocker.query('SELECT pg_backend_pid() AS pid'); - let started: (pid: number) => void; - const transactionStarted = new Promise((resolve) => { - started = resolve; - }); - const original = db.createQueryRunner.bind(db); - jest.spyOn(db, 'createQueryRunner').mockImplementation((...args) => { - const runner = original(...args); - const start = runner.startTransaction.bind(runner); - jest.spyOn(runner, 'startTransaction').mockImplementation(async (...startArgs) => { - await start(...startArgs); - const [{ pid }] = await runner.query('SELECT pg_backend_pid() AS pid'); - started(pid); - }); - return runner; - }); - pending = request(app) - .delete(`/api/${entity}/${row.id}`) - .then((response) => response); - const deletionPid = await Promise.race([ - transactionStarted, - pending.then(() => { - throw new Error('Deletion finished before starting its transaction'); - }), - ]); - let waiting = false; - const deadline = Date.now() + 4000; - while (!waiting && Date.now() < deadline) { - [{ waiting }] = await blocker.query('SELECT $2::integer = ANY(pg_blocking_pids($1::integer)) AS waiting', [ - deletionPid, - writerPid, - ]); - if (!waiting) await new Promise((resolve) => setImmediate(resolve)); - } - expect(waiting).toBe(true); - await blocker.commitTransaction(); - expect((await pending).status).toBe(400); - expect(await db.getRepository(model[entity]).countBy({ id: row.id })).toBe(1); - } finally { - if (blocker.isTransactionActive) await blocker.rollbackTransaction(); - await pending; - await blocker.release(); + const list = await ownedList(entity, row.id); + let ownerId: string; + if (entity === 'flags') { + await db.getRepository(FeatureFlag).update(row.id, { status: FEATURE_FLAG_STATUS.ENABLED }); + } else { + const [owner] = await create('flags'); + ownerId = owner.id; + await ownedList('flags', owner.id, row.id); } - }, - 10000 - ); + if (mode === 'single') { + await request(app).delete(`/api/${entity}/${row.id}`).expect(200); + } else { + const { body } = await request(app) + .post(route(entity)) + .send({ ids: [row.id] }) + .expect(200); + expect(body.results).toEqual([{ id: row.id, outcome: 'deleted' }]); + } + expect(await db.getRepository(model[entity]).countBy({ id: row.id })).toBe(0); + expect(await db.getRepository(Segment).countBy({ id: list.id })).toBe(0); + expect(await db.getRepository(IndividualForSegment).countBy({ segmentId: list.id })).toBe(0); + if (ownerId) expect(await db.getRepository(FeatureFlag).countBy({ id: ownerId })).toBe(1); + } + }); - test.each([SEGMENT_TYPE.PRIVATE, SEGMENT_TYPE.GLOBAL_EXCLUDE])( - 'single-delete rejects a %s segment through the ordinary root endpoint', - async (type) => { + test.each(entities)('%s single-delete preserves its original missing-target response', async (entity) => { + const { body } = await request(app) + .delete(`/api/${entity}/${randomUUID()}`) + .expect(entity === 'segments' ? 500 : 404); + if (entity === 'experiments') expect(body.message).toBe('Experiment not found.'); + }); + + test.each(['single', 'batch'])('%s deletion allows private and global-exclude segments', async (mode) => { + for (const type of [SEGMENT_TYPE.PRIVATE, SEGMENT_TYPE.GLOBAL_EXCLUDE]) { const [row] = await create('segments'); await db.getRepository(Segment).update(row.id, { type }); - await request(app).delete(`/api/segments/${row.id}`).expect(400); - expect(await db.getRepository(Segment).countBy({ id: row.id })).toBe(1); + if (mode === 'single') { + await request(app).delete(`/api/segments/${row.id}`).expect(200); + } else { + const { body } = await request(app) + .post(route('segments')) + .send({ ids: [row.id] }) + .expect(200); + expect(body.results).toEqual([{ id: row.id, outcome: 'deleted' }]); + } + expect(await db.getRepository(Segment).countBy({ id: row.id })).toBe(0); } - ); + }); test.each(entities)( '%s private-list endpoint still deletes lists and preserves their public children', @@ -599,25 +485,12 @@ export function registerBatchDeleteTests(connections: () => [DataSource, DataSou } ); - test('single Mooclet deletion rejects an ineligible experiment before local or remote deletion', async () => { - env.mooclets.enabled = true; - const [row] = await create('experiments'); - await db.getRepository(Experiment).update(row.id, { state: EXPERIMENT_STATE.DRAFT }); - const service = Container.get(MoocletExperimentService); - jest - .spyOn(service, 'getMoocletExperimentRefByUpgradeExperimentId') - .mockResolvedValue({ id: randomUUID() } as MoocletExperimentRef); - const remote = jest.spyOn(service, 'orchestrateDeleteMoocletResources').mockResolvedValue(true); - await request(app).delete(`/api/experiments/${row.id}`).expect(400); - expect(remote).not.toHaveBeenCalled(); - expect(await db.getRepository(Experiment).countBy({ id: row.id })).toBe(1); - }); - test.each([true, false])( 'single Mooclet deletion preserves commit/rollback when remote deletion returns %s', async (success) => { env.mooclets.enabled = true; const [row] = await create('experiments'); + await db.getRepository(Experiment).update(row.id, { state: EXPERIMENT_STATE.DRAFT }); const list = await ownedList('experiments', row.id); const service = Container.get(MoocletExperimentService); jest @@ -638,14 +511,14 @@ export function registerBatchDeleteTests(connections: () => [DataSource, DataSou ); test.each(entities.flatMap((entity) => [20, 100, 500].map((count) => ({ entity, count }))))( - '$entity deletes $count selections without repeating global eligibility reads per item', + '$entity deletes $count selections without global eligibility reads', async ({ entity, count }) => { const rows = await create(entity, count); const ids = rows.map((row) => row.id).reverse(); const status = jest.spyOn(Container.get(SegmentService), 'getSegmentStatus'); const { body } = await request(app).post(route(entity)).send({ ids }).expect(200); expect(body).toEqual({ phase: 'executed', results: ids.map((id) => ({ id, outcome: 'deleted' })) }); - expect(status).toHaveBeenCalledTimes(entity === 'segments' ? 1 : 0); + expect(status).not.toHaveBeenCalled(); expect(await db.getRepository(model[entity]).countBy({ id: In(ids) })).toBe(0); } ); diff --git a/packages/backend/test/integration/SegmentDeletionGuard/index.ts b/packages/backend/test/integration/SegmentDeletionGuard/index.ts deleted file mode 100644 index ebdca1def..000000000 --- a/packages/backend/test/integration/SegmentDeletionGuard/index.ts +++ /dev/null @@ -1,406 +0,0 @@ -import { randomUUID } from 'crypto'; -import Container from 'typedi'; -import { DataSource, EntityManager, QueryRunner } from 'typeorm'; -import { - ASSIGNMENT_UNIT, - EXPERIMENT_STATE, - POST_EXPERIMENT_RULE, - SEGMENT_STATUS, - SEGMENT_TYPE, - STANDARD_LIST_TYPE, -} from 'upgrade_types'; -import { Experiment } from '../../../src/api/models/Experiment'; -import { ExperimentSegmentInclusion } from '../../../src/api/models/ExperimentSegmentInclusion'; -import { ExperimentSegmentExclusion } from '../../../src/api/models/ExperimentSegmentExclusion'; -import { FeatureFlag } from '../../../src/api/models/FeatureFlag'; -import { FeatureFlagSegmentInclusion } from '../../../src/api/models/FeatureFlagSegmentInclusion'; -import { FeatureFlagSegmentExclusion } from '../../../src/api/models/FeatureFlagSegmentExclusion'; -import { IndividualForSegment } from '../../../src/api/models/IndividualForSegment'; -import { Segment } from '../../../src/api/models/Segment'; -import { SegmentService, SegmentWithStatus } from '../../../src/api/services/SegmentService'; -import { assertSegmentDeletionAllowed as assertAllowed } from '../../../src/api/services/batch/SegmentDeletionGuard'; -import { DeletionRepository } from '../../../src/api/repositories/DeletionRepository'; -import { Container as repositoryContainer } from '../../../src/typeorm-typedi-extensions'; -import { UpgradeLogger } from '../../../src/lib/logger/UpgradeLogger'; - -const changes = [ - 'experiment inclusion', - 'experiment exclusion', - 'flag inclusion', - 'flag exclusion', - 'nested experiment inclusion', - 'nested experiment exclusion', - 'nested flag inclusion', - 'nested flag exclusion', - 'nested parent', - 'archived owner state', - 'parent list type', -] as const; -type Change = (typeof changes)[number]; - -const barrier = () => { - let release: () => void; - const reached = new Promise((resolve) => { - release = resolve; - }); - return { reached, release }; -}; -const outcome = (work: Promise): Promise<{ value?: T; error?: any }> => - work.then( - (value) => ({ value }), - (error) => ({ error }) - ); - -const waitForBarrier = (signal: ReturnType, work: ReturnType) => - Promise.race([ - signal.reached, - work.then((result) => { - throw result.error || new Error('Deletion completed before the test barrier'); - }), - ]); - -const createSegment = async (db: DataSource, type = SEGMENT_TYPE.PUBLIC, children: Segment[] = []) => { - const repo = db.getRepository(Segment); - return repo.save( - repo.create({ - id: randomUUID(), - name: 'Guard fixture', - context: 'guard-test', - type, - listType: children.length ? STANDARD_LIST_TYPE.SEGMENT : STANDARD_LIST_TYPE.INDIVIDUAL, - subSegments: children, - }) - ); -}; - -const createChange = async (db: DataSource, change: Change) => { - const target = await createSegment(db); - await db.getRepository(IndividualForSegment).insert({ segmentId: target.id, userId: 'retained-on-rejection' }); - const nested = change.startsWith('nested') || change === 'archived owner state' || change === 'parent list type'; - const referenced = nested ? await createSegment(db, SEGMENT_TYPE.PRIVATE, [target]) : target; - let apply: (manager: EntityManager) => Promise; - - if (change.includes('experiment') || change === 'archived owner state') { - const repo = db.getRepository(Experiment); - const experiment = await repo.save( - repo.create({ - id: randomUUID(), - name: 'Guard owner', - description: '', - context: ['guard-test'], - state: change === 'archived owner state' ? EXPERIMENT_STATE.ARCHIVED : EXPERIMENT_STATE.INACTIVE, - assignmentUnit: ASSIGNMENT_UNIT.INDIVIDUAL, - postExperimentRule: POST_EXPERIMENT_RULE.CONTINUE, - }) - ); - const entity = change.includes('exclusion') ? ExperimentSegmentExclusion : ExperimentSegmentInclusion; - const ref = { experimentId: experiment.id, segmentId: referenced.id }; - apply = (manager) => manager.getRepository(entity).insert(ref); - if (change === 'archived owner state') { - await apply(db.manager); - apply = (manager) => - manager.getRepository(Experiment).update(experiment.id, { state: EXPERIMENT_STATE.INACTIVE }); - } - } else if (change.includes('flag')) { - const repo = db.getRepository(FeatureFlag); - const flag = await repo.save( - repo.create({ - id: randomUUID(), - name: 'Guard owner', - key: randomUUID(), - description: '', - context: ['guard-test'], - }) - ); - const entity = change.includes('exclusion') ? FeatureFlagSegmentExclusion : FeatureFlagSegmentInclusion; - apply = (manager) => - manager.getRepository(entity).insert({ - featureFlagId: flag.id, - segmentId: referenced.id, - enabled: false, - listType: STANDARD_LIST_TYPE.SEGMENT, - }); - } else { - const parent = await createSegment(db); - apply = (manager) => manager.createQueryBuilder().relation(Segment, 'subSegments').of(parent.id).add(referenced.id); - if (change === 'parent list type') { - await db.getRepository(Segment).update(referenced.id, { listType: STANDARD_LIST_TYPE.INDIVIDUAL }); - await apply(db.manager); - apply = (manager) => - manager.getRepository(Segment).update(referenced.id, { listType: STANDARD_LIST_TYPE.SEGMENT }); - } - } - return { target, referenced, apply, writerCanFinishAfterDeletion: nested }; -}; - -const backendPid = async (manager: EntityManager) => - Number((await manager.query('SELECT pg_backend_pid() AS pid'))[0].pid); - -// Observe an actual PostgreSQL lock dependency; no sleep duration determines transaction ordering. -const waitForBlock = async (db: DataSource, blocked: number, blocker: number) => { - const deadline = Date.now() + 4000; - while (Date.now() < deadline) { - const [{ waiting }] = await db.query('SELECT $2::integer = ANY(pg_blocking_pids($1::integer)) AS waiting', [ - blocked, - blocker, - ]); - if (waiting) return; - await new Promise((resolve) => setImmediate(resolve)); - } - throw new Error(`Expected PostgreSQL backend ${blocked} to wait for ${blocker}`); -}; - -const startWriter = async (db: DataSource) => { - const writer = db.createQueryRunner(); - await writer.connect(); - await writer.startTransaction(); - await writer.query("SET LOCAL lock_timeout = '8s'"); - return writer; -}; -const closeWriter = async (writer: QueryRunner) => { - if (writer.isTransactionActive) await writer.rollbackTransaction(); - await writer.release(); -}; - -export function registerSegmentDeletionGuardTests(connections: () => [DataSource, DataSource]) { - describe('Segment deletion guard', () => { - const logger = new UpgradeLogger(); - let db: DataSource; - let writerDb: DataSource; - let service: SegmentService; - let deletionRepository: DeletionRepository; - const assertSegmentDeletionAllowed = (id: string, manager: EntityManager) => - assertAllowed(id, manager, deletionRepository); - beforeEach(() => { - [db, writerDb] = connections(); - service = Container.get(SegmentService); - deletionRepository = repositoryContainer.getCustomRepository(DeletionRepository); - }); - - const status = async (id: string) => { - const segment = await db - .getRepository(Segment) - .findOneOrFail({ where: { id }, relations: { subSegments: true } }); - const result = (await service.getSegmentStatus([segment])).segmentsData[0] as SegmentWithStatus; - return result.status; - }; - - test.each(changes)('%s committed before the guard is rejected', async (change) => { - const fixture = await createChange(db, change); - expect(await status(fixture.target.id)).toBe(SEGMENT_STATUS.UNUSED); - await fixture.apply(writerDb.manager); - expect(await status(fixture.target.id)).toBe(SEGMENT_STATUS.USED); - await expect( - service.deleteSegment(fixture.target.id, logger, (manager) => - assertSegmentDeletionAllowed(fixture.target.id, manager) - ) - ).rejects.toMatchObject({ reason: 'used' }); - expect(await db.getRepository(IndividualForSegment).countBy({ segmentId: fixture.target.id })).toBe(1); - }); - - test.each(changes)( - '%s in progress is awaited and then rejected', - async (change) => { - const fixture = await createChange(db, change); - expect(await status(fixture.target.id)).toBe(SEGMENT_STATUS.UNUSED); - const writer = await startWriter(writerDb); - const entered = barrier(); - let deleterPid: number; - let deletion: ReturnType; - try { - const writerPid = await backendPid(writer.manager); - await fixture.apply(writer.manager); - deletion = outcome( - service.deleteSegment(fixture.target.id, logger, async (manager) => { - deleterPid = await backendPid(manager); - entered.release(); - await assertSegmentDeletionAllowed(fixture.target.id, manager); - }) - ); - await waitForBarrier(entered, deletion); - expect(deleterPid).not.toBe(writerPid); - await waitForBlock(db, deleterPid, writerPid); - await writer.commitTransaction(); - expect((await deletion).error).toMatchObject({ reason: 'used' }); - expect(await db.getRepository(Segment).countBy({ id: fixture.target.id })).toBe(1); - expect(await db.getRepository(IndividualForSegment).countBy({ segmentId: fixture.target.id })).toBe(1); - } finally { - await closeWriter(writer); - await deletion; - } - }, - 15000 - ); - - test.each(changes)( - '%s waits until an already guarded deletion commits', - async (change) => { - const fixture = await createChange(db, change); - expect(await status(fixture.target.id)).toBe(SEGMENT_STATUS.UNUSED); - const writer = await startWriter(writerDb); - const guarded = barrier(); - const finish = barrier(); - let deleterPid: number; - const deletion = outcome( - service.deleteSegment(fixture.target.id, logger, async (manager) => { - deleterPid = await backendPid(manager); - await assertSegmentDeletionAllowed(fixture.target.id, manager); - guarded.release(); - await finish.reached; - }) - ); - let writing: ReturnType; - try { - await waitForBarrier(guarded, deletion); - const writerPid = await backendPid(writer.manager); - expect(deleterPid).not.toBe(writerPid); - writing = outcome(fixture.apply(writer.manager)); - await waitForBlock(db, writerPid, deleterPid); - finish.release(); - expect((await deletion).error).toBeUndefined(); - const result = await writing; - if (fixture.writerCanFinishAfterDeletion) { - expect(result.error).toBeUndefined(); - await writer.commitTransaction(); - } else { - expect(result.error).toMatchObject({ code: '23503' }); - await writer.rollbackTransaction(); - } - expect(await db.getRepository(Segment).countBy({ id: fixture.target.id })).toBe(0); - } finally { - finish.release(); - await writing; - await closeWriter(writer); - await deletion; - } - }, - 15000 - ); - - test.each(['commit', 'rollback'] as const)( - 'bounds lock waits without leaking the setting after %s', - async (completion) => { - const target = await createSegment(db); - const runner = db.createQueryRunner(); - await runner.connect(); - const [original] = await runner.query('SHOW lock_timeout'); - try { - for (const [configured, expected] of [ - ['0', '5s'], - ['30s', '5s'], - ['200ms', '200ms'], - ]) { - await runner.query("SELECT set_config('lock_timeout', $1, false)", [configured]); - await runner.startTransaction(); - await assertSegmentDeletionAllowed(target.id, runner.manager); - expect(await runner.query('SHOW lock_timeout')).toEqual([{ lock_timeout: expected }]); - if (completion === 'commit') await runner.commitTransaction(); - else await runner.rollbackTransaction(); - // Check the same pinned connection, not an arbitrary pooled connection. - expect(await runner.query('SHOW lock_timeout')).toEqual([{ lock_timeout: configured }]); - } - } finally { - if (runner.isTransactionActive) await runner.rollbackTransaction(); - await runner.query("SELECT set_config('lock_timeout', $1, false)", [original.lock_timeout]); - await runner.release(); - } - } - ); - - test.each(['target', 'ancestor', 'experiment owner', 'private cleanup'] as const)( - 'a lock timeout during %s rejects deletion and rolls back prior writes', - async (blockedAt) => { - const fixture = await createChange(db, 'archived owner state'); - const child = blockedAt === 'private cleanup' ? await createSegment(db, SEGMENT_TYPE.PRIVATE) : undefined; - if (child) { - await db.createQueryBuilder().relation(Segment, 'subSegments').of(fixture.target.id).add(child.id); - } - const writer = await startWriter(writerDb); - let guardPassed = false; - try { - if (blockedAt === 'experiment owner') { - await writer.query( - `SELECT id FROM experiment WHERE id IN ( - SELECT "experimentId" FROM experiment_segment_inclusion WHERE "segmentId" = $1 - ) FOR UPDATE`, - [fixture.referenced.id] - ); - } else { - const lockedId = child?.id ?? (blockedAt === 'ancestor' ? fixture.referenced.id : fixture.target.id); - await writer.query('SELECT id FROM segment WHERE id = $1 FOR UPDATE', [lockedId]); - } - const writerPid = await backendPid(writer.manager); - await expect( - service.deleteSegment(fixture.target.id, logger, async (manager) => { - expect(await backendPid(manager)).not.toBe(writerPid); - // A tighter transaction budget keeps this contention test fast and must be preserved. - await manager.query("SET LOCAL lock_timeout = '200ms'"); - await manager.getRepository(IndividualForSegment).delete({ segmentId: fixture.target.id }); - await assertSegmentDeletionAllowed(fixture.target.id, manager); - guardPassed = true; - }) - ).rejects.toMatchObject({ code: '55P03' }); - // The writer still holds its lock: failure comes from the timeout, not releasing the blocker. - expect(writer.isTransactionActive).toBe(true); - expect(guardPassed).toBe(blockedAt === 'private cleanup'); - expect(await db.getRepository(Segment).countBy({ id: fixture.target.id })).toBe(1); - expect(await db.getRepository(IndividualForSegment).countBy({ segmentId: fixture.target.id })).toBe(1); - if (child) expect(await db.getRepository(Segment).countBy({ id: child.id })).toBe(1); - } finally { - await closeWriter(writer); - } - }, - 15000 - ); - - test('rejects missing/protected targets and calls without a transaction', async () => { - await expect(assertSegmentDeletionAllowed(randomUUID(), db.manager)).rejects.toThrow('active transaction'); - await expect( - db.transaction('REPEATABLE READ', (manager) => assertSegmentDeletionAllowed(randomUUID(), manager)) - ).rejects.toThrow('READ COMMITTED'); - await expect( - db.transaction((manager) => assertSegmentDeletionAllowed(randomUUID(), manager)) - ).rejects.toMatchObject({ reason: 'missing' }); - for (const type of [SEGMENT_TYPE.PRIVATE, SEGMENT_TYPE.GLOBAL_EXCLUDE]) { - const segment = await createSegment(db, type); - await expect( - service.deleteSegment(segment.id, logger, (manager) => assertSegmentDeletionAllowed(segment.id, manager)) - ).rejects.toMatchObject({ reason: 'protected' }); - expect(await db.getRepository(Segment).countBy({ id: segment.id })).toBe(1); - } - }); - - test('guard rollback preserves prior writes and legacy deletion remains available', async () => { - const fixture = await createChange(db, 'flag inclusion'); - await fixture.apply(db.manager); - await expect( - service.deleteSegment(fixture.target.id, logger, async (manager) => { - await manager.getRepository(IndividualForSegment).delete({ segmentId: fixture.target.id }); - await assertSegmentDeletionAllowed(fixture.target.id, manager); - }) - ).rejects.toMatchObject({ reason: 'used' }); - expect(await db.getRepository(IndividualForSegment).countBy({ segmentId: fixture.target.id })).toBe(1); - await expect(service.deleteSegment(fixture.target.id, logger)).resolves.toMatchObject({ id: fixture.target.id }); - }); - - test('guarded deletion awaits private cleanup without global status loaders', async () => { - const child = await createSegment(db, SEGMENT_TYPE.PRIVATE); - const target = await createSegment(db, SEGMENT_TYPE.PUBLIC, [child]); - const spies = [ - 'getSegmentStatus', - 'getExperimentSegmentInclusionData', - 'getExperimentSegmentExclusionData', - 'getFeatureFlagSegmentInclusionData', - 'getFeatureFlagSegmentExclusionData', - 'getParentSegments', - ].map((method) => jest.spyOn(service as any, method)); - try { - await service.deleteSegment(target.id, logger, (manager) => assertSegmentDeletionAllowed(target.id, manager)); - expect(await db.getRepository(Segment).countBy({ id: child.id })).toBe(0); - spies.forEach((spy) => expect(spy).not.toHaveBeenCalled()); - } finally { - spies.forEach((spy) => spy.mockRestore()); - } - }); - }); -} diff --git a/packages/backend/test/integration/index.test.ts b/packages/backend/test/integration/index.test.ts index bbb138b23..267f4f244 100644 --- a/packages/backend/test/integration/index.test.ts +++ b/packages/backend/test/integration/index.test.ts @@ -124,7 +124,6 @@ import { ExperimentValidation } from './Experiment/validation'; import { FeatureFlagInclusionExclusion } from './FeatureFlags'; import { ListValueFiltering } from './ListValueFiltering'; import { FeatureFlagDeleteCleanup, SegmentDeleteCleanup } from './DeleteCleanup'; -import { registerSegmentDeletionGuardTests } from './SegmentDeletionGuard'; import { registerBatchDeleteTests } from './BatchDelete'; describe('Integration Tests', () => { @@ -135,7 +134,6 @@ describe('Integration Tests', () => { let defaultConnection: DataSource; let exportConnection: DataSource; - registerSegmentDeletionGuardTests(() => [defaultConnection, exportConnection]); registerBatchDeleteTests(() => [defaultConnection, exportConnection]); beforeAll(async () => { configureLogger(); diff --git a/packages/backend/test/unit/controllers/ExperimentController.test.ts b/packages/backend/test/unit/controllers/ExperimentController.test.ts index be5c061fe..96138b37e 100644 --- a/packages/backend/test/unit/controllers/ExperimentController.test.ts +++ b/packages/backend/test/unit/controllers/ExperimentController.test.ts @@ -1,5 +1,4 @@ import { BatchDeleteService } from '../../../src/api/services/batch/BatchDeleteService'; -import { DeletionStateService } from '../../../src/api/services/DeletionStateService'; import app from '../../utils/expressApp'; import request from 'supertest'; import { configureLogger } from '../../utils/logger'; @@ -37,7 +36,6 @@ describe('Experiment Controller Testing', () => { routingUseContainer(Container); classValidatorUseContainer(Container); Container.set(BatchDeleteService, {} as BatchDeleteService); - Container.set(DeletionStateService, { transactionFor: jest.fn() } as unknown as DeletionStateService); // set mock container Container.set(ExperimentService, new ExperimentServiceMock()); diff --git a/packages/backend/test/unit/controllers/FeatureFlagController.test.ts b/packages/backend/test/unit/controllers/FeatureFlagController.test.ts index cc5b00300..956ff305f 100644 --- a/packages/backend/test/unit/controllers/FeatureFlagController.test.ts +++ b/packages/backend/test/unit/controllers/FeatureFlagController.test.ts @@ -1,5 +1,4 @@ import { BatchDeleteService } from '../../../src/api/services/batch/BatchDeleteService'; -import { DeletionStateService } from '../../../src/api/services/DeletionStateService'; import app from '../../utils/expressApp'; import request from 'supertest'; import { configureLogger } from '../../utils/logger'; @@ -21,7 +20,6 @@ describe('Feature Flag Controller Testing', () => { routingUseContainer(Container); classValidatorUseContainer(Container); Container.set(BatchDeleteService, {} as BatchDeleteService); - Container.set(DeletionStateService, { transactionFor: jest.fn() } as unknown as DeletionStateService); Container.set(FeatureFlagService, new FeatureFlagServiceMock()); Container.set(ExperimentUserService, new ExperimentUserServiceMock()); diff --git a/packages/backend/test/unit/controllers/SegmentController.test.ts b/packages/backend/test/unit/controllers/SegmentController.test.ts index 7394b4436..6b9ee6dbc 100644 --- a/packages/backend/test/unit/controllers/SegmentController.test.ts +++ b/packages/backend/test/unit/controllers/SegmentController.test.ts @@ -1,5 +1,4 @@ import { BatchDeleteService } from '../../../src/api/services/batch/BatchDeleteService'; -import { DeletionStateService } from '../../../src/api/services/DeletionStateService'; import app from '../../utils/expressApp'; import request from 'supertest'; import { configureLogger } from '../../utils/logger'; @@ -17,7 +16,6 @@ describe('Segment Controller Testing', () => { routingUseContainer(Container); classValidatorUseContainer(Container); Container.set(BatchDeleteService, {} as BatchDeleteService); - Container.set(DeletionStateService, { transactionFor: jest.fn() } as unknown as DeletionStateService); // set mock container Container.set(SegmentService, new SegmentServiceMock()); diff --git a/packages/backend/test/unit/services/batch/BatchDeleteService.test.ts b/packages/backend/test/unit/services/batch/BatchDeleteService.test.ts index 93335aaac..85c1ee556 100644 --- a/packages/backend/test/unit/services/batch/BatchDeleteService.test.ts +++ b/packages/backend/test/unit/services/batch/BatchDeleteService.test.ts @@ -3,10 +3,6 @@ import { performance } from 'perf_hooks'; import { DataSource, EntityManager, QueryRunner } from 'typeorm'; import { BatchDeleteEntity, DeletionReasonCode, UserRole } from 'upgrade_types'; import { BatchDeleteService } from '../../../../src/api/services/batch/BatchDeleteService'; -import { - DeletionEligibilityService, - DeletionEligibilityResult, -} from '../../../../src/api/services/batch/DeletionEligibilityService'; import { ExperimentService } from '../../../../src/api/services/ExperimentService'; import { FeatureFlagService } from '../../../../src/api/services/FeatureFlagService'; import { SegmentService } from '../../../../src/api/services/SegmentService'; @@ -14,7 +10,6 @@ import { MoocletExperimentService } from '../../../../src/api/services/MoocletEx import { MoocletError } from '../../../../src/api/errors/MoocletError'; import { UpgradeLogger } from '../../../../src/lib/logger/UpgradeLogger'; import { env } from '../../../../src/env'; -import * as segmentGuard from '../../../../src/api/services/batch/SegmentDeletionGuard'; import { DeletionRepository } from '../../../../src/api/repositories/DeletionRepository'; jest.mock('perf_hooks', () => ({ performance: { now: jest.fn(() => 0) } })); @@ -27,7 +22,6 @@ describe('BatchDeleteService transaction outcomes', () => { let service: BatchDeleteService; let runners: QueryRunner[]; let mutations: string[]; - let eligibility: Record; let experiments: { delete: jest.Mock }; let flags: { delete: jest.Mock }; let segments: { deleteSegment: jest.Mock }; @@ -44,11 +38,6 @@ describe('BatchDeleteService transaction outcomes', () => { (performance.now as jest.Mock).mockReturnValue(0); originalMooclet = env.mooclets.enabled; env.mooclets.enabled = false; - jest.spyOn(segmentGuard, 'assertSegmentDeletionAllowed').mockResolvedValue(undefined); - const eligible = async (requested: string[]): Promise => ({ - items: requested.map((id) => ({ id, availability: 'present', canDelete: true })), - }); - eligibility = { experiments: jest.fn(eligible), flags: jest.fn(eligible), segments: jest.fn(eligible) }; configureRunner = () => undefined; createQueryRunner = jest.fn(() => { let active = false; @@ -59,7 +48,7 @@ describe('BatchDeleteService transaction outcomes', () => { manager: { query: jest.fn().mockResolvedValue([]), getRepository: jest.fn(() => ({ - findOne: jest.fn(async ({ where }) => ({ id: where.id, state: 'inactive', status: 'disabled' })), + findOne: jest.fn(async ({ where }) => ({ id: where.id })), })), }, connect: jest.fn().mockResolvedValue(undefined), @@ -85,9 +74,8 @@ describe('BatchDeleteService transaction outcomes', () => { experiments = { delete: jest.fn((id, _user, options) => options.executeTransaction(() => work(id))) }; flags = { delete: jest.fn((id, _user, _logger, transaction) => transaction(() => work(id))) }; segments = { - deleteSegment: jest.fn((id, _logger, guard, transaction) => - transaction(async (manager) => { - await guard(manager); + deleteSegment: jest.fn((id, _logger, transaction) => + transaction(async () => { const [row] = await work(id); return row; }) @@ -99,7 +87,6 @@ describe('BatchDeleteService transaction outcomes', () => { }; service = new BatchDeleteService( { createQueryRunner } as unknown as DataSource, - eligibility as unknown as DeletionEligibilityService, experiments as unknown as ExperimentService, flags as unknown as FeatureFlagService, segments as unknown as SegmentService, @@ -112,19 +99,17 @@ describe('BatchDeleteService transaction outcomes', () => { jest.restoreAllMocks(); }); - test.each(entities)('%s rejects unauthorized users before any preflight or mutation', async (entity) => { + test.each(entities)('%s rejects unauthorized users before any transaction or mutation', async (entity) => { await expect(service.delete(entity, ids, undefined, logger)).rejects.toMatchObject({ httpCode: 401 }); for (const role of [UserRole.READER, undefined, 'unknown' as UserRole]) { await expect(service.delete(entity, ids, { ...user, role }, logger)).rejects.toMatchObject({ httpCode: 403 }); } - expect(eligibility[entity]).not.toHaveBeenCalled(); expect(createQueryRunner).not.toHaveBeenCalled(); }); test.each(entities)('%s commits sequentially and normalizes existing service responses', async (entity) => { const result = await service.delete(entity, ids, user, logger); expect(result).toEqual({ phase: 'executed', results: ids.map((id) => ({ id, outcome: 'deleted' })) }); - expect(eligibility[entity]).toHaveBeenCalledTimes(1); expect(mutations).toEqual(ids); for (const runner of runners) { expect(runner.commitTransaction).toHaveBeenCalledTimes(1); @@ -133,53 +118,35 @@ describe('BatchDeleteService transaction outcomes', () => { } }); - test('skips ineligible and missing selections while deleting eligible items', async () => { - eligibility.segments.mockResolvedValue({ - items: [ - { id: ids[0], canDelete: true, availability: 'present' }, - { id: ids[1], canDelete: false, availability: 'present', reasonCode: DeletionReasonCode.SEGMENT_IN_USE }, - { id: ids[2], canDelete: false, availability: 'not_found', reasonCode: DeletionReasonCode.NOT_FOUND }, - ], - }); - const result = await service.delete('segments', ids, user, logger); - expect(result.phase).toBe('executed'); - expect(result.results.map((item) => item.outcome)).toEqual(['deleted', 'ineligible', 'not_found']); - expect(createQueryRunner).toHaveBeenCalledTimes(1); - expect(mutations).toEqual([ids[0]]); + test('stops without mutation when the target lookup fails', async () => { + configureRunner = (runner) => { + (runner.manager.getRepository as jest.Mock).mockReturnValue({ + findOne: jest.fn().mockRejectedValue(new Error('read failed')), + }); + }; + const result = await service.delete('flags', ids, user, logger); + expect(result.results.map((item) => item.outcome)).toEqual(['failed', 'not_attempted', 'not_attempted']); + expect(result.results[0].reasonCode).toBe(DeletionReasonCode.DELETE_FAILED); + expect(mutations).toEqual([]); + expect(runners[0].rollbackTransaction).toHaveBeenCalledTimes(1); }); - test('returns a no-mutation result when preflight cannot be completed', async () => { - eligibility.flags.mockRejectedValue(new Error('read failed')); + test('reports no attempted mutation when every target is missing', async () => { + configureRunner = (runner) => { + (runner.manager.getRepository as jest.Mock).mockReturnValue({ findOne: jest.fn().mockResolvedValue(null) }); + }; expect(await service.delete('flags', ids, user, logger)).toEqual({ phase: 'rejected', - results: ids.map((id) => ({ - id, - outcome: 'not_attempted', - reasonCode: DeletionReasonCode.ELIGIBILITY_UNAVAILABLE, - })), + results: ids.map((id) => ({ id, outcome: 'not_found', reasonCode: DeletionReasonCode.NOT_FOUND })), }); - expect(createQueryRunner).not.toHaveBeenCalled(); - }); - - test('does not start transactions when every selection is ineligible', async () => { - eligibility.flags.mockResolvedValue({ - items: ids.map((id) => ({ - id, - canDelete: false, - availability: 'present', - reasonCode: DeletionReasonCode.FEATURE_FLAG_ENABLED, - })), - }); - const result = await service.delete('flags', ids, user, logger); - expect(result.phase).toBe('rejected'); - expect(result.results.map((item) => item.outcome)).toEqual(['ineligible', 'ineligible', 'ineligible']); - expect(createQueryRunner).not.toHaveBeenCalled(); + expect(mutations).toEqual([]); + expect(runners.every((runner) => (runner.rollbackTransaction as jest.Mock).mock.calls.length === 1)).toBe(true); }); - test('stops when rollback fails even if the target was rejected before mutation', async () => { + test('stops when rollback fails even if the target was missing before mutation', async () => { configureRunner = (runner) => { (runner.manager.getRepository as jest.Mock).mockReturnValue({ - findOne: jest.fn().mockResolvedValue({ status: 'enabled' }), + findOne: jest.fn().mockResolvedValue(null), }); (runner.rollbackTransaction as jest.Mock).mockRejectedValue(new Error('connection lost')); }; @@ -204,15 +171,15 @@ describe('BatchDeleteService transaction outcomes', () => { expect(mutations).toEqual(ids.slice(0, index)); }); - test.each(['missing', 'enabled'])('rejects a %s flag found by the locked mutation check', async (changed) => { + test.each(entities)('%s skips a missing locked target and continues deletion', async (entity) => { configureRunner = (runner, index) => { if (index !== 0) return; (runner.manager.getRepository as jest.Mock).mockReturnValue({ - findOne: jest.fn().mockResolvedValue(changed === 'missing' ? null : { id: ids[0], status: 'enabled' }), + findOne: jest.fn().mockResolvedValue(null), }); }; - const result = await service.delete('flags', ids, user, logger); - expect(result.results[0].outcome).toBe(changed === 'missing' ? 'not_found' : 'ineligible'); + const result = await service.delete(entity, ids, user, logger); + expect(result.results[0].outcome).toBe('not_found'); expect(result.results.slice(1).map((item) => item.outcome)).toEqual(['deleted', 'deleted']); expect(mutations).toEqual(ids.slice(1)); }); @@ -224,29 +191,6 @@ describe('BatchDeleteService transaction outcomes', () => { expect(runners[0].rollbackTransaction).toHaveBeenCalled(); }); - test.each(['missing', 'protected', 'used'] as const)( - 'reports a segment becoming %s after preflight', - async (reason) => { - jest - .spyOn(segmentGuard, 'assertSegmentDeletionAllowed') - .mockRejectedValueOnce(new segmentGuard.SegmentDeletionBlockedError(ids[0], reason)); - const result = await service.delete('segments', ids, user, logger); - expect(result.results[0]).toEqual({ - id: ids[0], - outcome: reason === 'missing' ? 'not_found' : 'ineligible', - reasonCode: - reason === 'missing' - ? DeletionReasonCode.NOT_FOUND - : reason === 'protected' - ? DeletionReasonCode.PROTECTED_SEGMENT_TYPE - : DeletionReasonCode.SEGMENT_IN_USE, - }); - expect(result.results.slice(1).every((item) => item.outcome === 'deleted')).toBe(true); - expect(mutations).toEqual(ids.slice(1)); - expect(runners[0].rollbackTransaction).toHaveBeenCalled(); - } - ); - test('does not claim rollback when COMMIT acknowledgment is lost, even if ROLLBACK succeeds', async () => { configureRunner = (runner) => { (runner.commitTransaction as jest.Mock).mockRejectedValue(new Error('connection lost')); @@ -345,13 +289,8 @@ describe('BatchDeleteService transaction outcomes', () => { expect(createQueryRunner).toHaveBeenCalledTimes(1); }); - test('does not start execution if the preflight consumes the budget', async () => { - const original = eligibility.flags.getMockImplementation(); - eligibility.flags.mockImplementation(async (...args) => { - const result = await original(...args); - (performance.now as jest.Mock).mockReturnValue(60_001); - return result; - }); + test('does not start execution if the admission budget has expired', async () => { + (performance.now as jest.Mock).mockReturnValueOnce(0).mockReturnValue(60_001); expect(await service.delete('flags', ids, user, logger)).toEqual({ phase: 'rejected', results: ids.map((id) => ({ @@ -363,12 +302,12 @@ describe('BatchDeleteService transaction outcomes', () => { expect(createQueryRunner).not.toHaveBeenCalled(); }); - test('does not mutate when the budget expires while acquiring eligibility locks', async () => { + test('does not mutate when the budget expires while acquiring the target lock', async () => { configureRunner = (runner) => { (runner.manager.getRepository as jest.Mock).mockReturnValue({ findOne: jest.fn(async () => { (performance.now as jest.Mock).mockReturnValue(60_001); - return { id: ids[0], status: 'disabled' }; + return { id: ids[0] }; }), }); }; diff --git a/packages/backend/test/unit/services/batch/DeletionEligibilityService.test.ts b/packages/backend/test/unit/services/batch/DeletionEligibilityService.test.ts deleted file mode 100644 index 914a10aa7..000000000 --- a/packages/backend/test/unit/services/batch/DeletionEligibilityService.test.ts +++ /dev/null @@ -1,111 +0,0 @@ -import { randomUUID } from 'crypto'; -import { DeletionReasonCode, EXPERIMENT_STATE, FEATURE_FLAG_STATUS, SEGMENT_STATUS, SEGMENT_TYPE } from 'upgrade_types'; -import { DeletionEligibilityService } from '../../../../src/api/services/batch/DeletionEligibilityService'; -import { SegmentService } from '../../../../src/api/services/SegmentService'; -import { ExperimentRepository } from '../../../../src/api/repositories/ExperimentRepository'; -import { FeatureFlagRepository } from '../../../../src/api/repositories/FeatureFlagRepository'; -import { SegmentRepository } from '../../../../src/api/repositories/SegmentRepository'; - -const methods = ['experiments', 'flags', 'segments'] as const; - -describe('Internal batch deletion eligibility', () => { - const id = randomUUID(); - let service: DeletionEligibilityService; - let findExperiments: jest.Mock; - let findFlags: jest.Mock; - let findSegments: jest.Mock; - let getSegmentStatus: jest.Mock; - beforeEach(() => { - const row = { - id, - name: 'Selected', - state: EXPERIMENT_STATE.INACTIVE, - status: FEATURE_FLAG_STATUS.DISABLED, - type: SEGMENT_TYPE.PUBLIC, - subSegments: [], - }; - findExperiments = jest.fn().mockResolvedValue([row]); - findFlags = jest.fn().mockResolvedValue([row]); - findSegments = jest.fn().mockResolvedValue([row]); - getSegmentStatus = jest.fn().mockResolvedValue({ segmentsData: [{ ...row, status: SEGMENT_STATUS.UNUSED }] }); - service = new DeletionEligibilityService( - { findForDeletionEligibility: findExperiments } as unknown as ExperimentRepository, - { findForDeletionEligibility: findFlags } as unknown as FeatureFlagRepository, - { getSegmentStatus } as unknown as SegmentService, - { findForDeletionEligibility: findSegments } as unknown as SegmentRepository - ); - }); - - test('preserves experiment state aliases and reports missing IDs in request order', async () => { - const states = [ - EXPERIMENT_STATE.INACTIVE, - EXPERIMENT_STATE.ENROLLING, - EXPERIMENT_STATE.ENROLLMENT_COMPLETE, - EXPERIMENT_STATE.CANCELLED, - EXPERIMENT_STATE.ARCHIVED, - EXPERIMENT_STATE.DRAFT, - EXPERIMENT_STATE.PREVIEW, - EXPERIMENT_STATE.SCHEDULED, - ]; - const rows = states.map((state) => ({ id: randomUUID(), state })); - findExperiments.mockResolvedValue([...rows].reverse()); - const ids = [...rows.map((row) => row.id.toUpperCase()), randomUUID()]; - const result = await service.experiments(ids); - expect(result.items.map((item) => item.id)).toEqual(ids); - expect(result.items.map((item) => item.canDelete)).toEqual([ - true, - true, - true, - true, - true, - false, - false, - false, - false, - ]); - expect(result.items[result.items.length - 1].reasonCode).toBe(DeletionReasonCode.NOT_FOUND); - }); - - test('allows disabled and archived flags while rejecting enabled flags', async () => { - const rows = [FEATURE_FLAG_STATUS.DISABLED, FEATURE_FLAG_STATUS.ARCHIVED, FEATURE_FLAG_STATUS.ENABLED].map( - (status) => ({ id: randomUUID(), status }) - ); - findFlags.mockResolvedValue(rows); - const result = await service.flags(rows.map((row) => row.id)); - expect(result.items.map((item) => item.canDelete)).toEqual([true, true, false]); - expect(result.items[2].reasonCode).toBe(DeletionReasonCode.FEATURE_FLAG_ENABLED); - }); - - test.each(methods)('%s propagates database failure instead of reporting absence', async (method) => { - const failure = new Error('Database unavailable'); - findExperiments.mockRejectedValue(failure); - findFlags.mockRejectedValue(failure); - findSegments.mockRejectedValue(failure); - await expect(service[method]([id])).rejects.toBe(failure); - expect(getSegmentStatus).not.toHaveBeenCalled(); - }); - - test('propagates a failed shared segment status read', async () => { - const failure = new Error('Status read failed'); - getSegmentStatus.mockRejectedValue(failure); - await expect(service.segments([id])).rejects.toBe(failure); - }); - - test('does not declare an existing segment deletable when its derived status is unavailable', async () => { - getSegmentStatus.mockResolvedValue({ segmentsData: [] }); - const result = await service.segments([id]); - expect(result.items[0]).toMatchObject({ - id, - availability: 'unavailable', - canDelete: false, - reasonCode: DeletionReasonCode.ELIGIBILITY_UNAVAILABLE, - }); - }); - - test('does not run global status reads when no selected segment exists', async () => { - findSegments.mockResolvedValue([]); - const result = await service.segments([id]); - expect(result.items[0]).toMatchObject({ availability: 'not_found', canDelete: false }); - expect(getSegmentStatus).not.toHaveBeenCalled(); - }); -}); diff --git a/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.helpers.spec.ts b/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.helpers.spec.ts index a3d95cbe8..9d44d4f2a 100644 --- a/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.helpers.spec.ts +++ b/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.helpers.spec.ts @@ -1,6 +1,5 @@ import { BatchDeleteEntity, - DeletionReasonCode, EXPERIMENT_STATE, FEATURE_FLAG_STATUS, SEGMENT_STATUS, @@ -9,7 +8,12 @@ import { } from 'upgrade_types'; import { createBatchActions } from './batch-actions.actions'; import { localDeletionReason, selectionView, validateBatchResponse } from './batch-actions.helpers'; -import { RootBatchState, RootSelectionItem, initialRootBatchState } from './batch-actions.models'; +import { + BatchSelectionReasonCode, + RootBatchState, + RootSelectionItem, + initialRootBatchState, +} from './batch-actions.models'; import { reduceRootBatch } from './batch-actions.reducer'; describe('Root selection rules', () => { @@ -71,47 +75,37 @@ describe('Root selection rules', () => { ).toEqual({}); }); - it.each([ - EXPERIMENT_STATE.INACTIVE, - EXPERIMENT_STATE.ENROLLING, - EXPERIMENT_STATE.ENROLLMENT_COMPLETE, - EXPERIMENT_STATE.CANCELLED, - EXPERIMENT_STATE.ARCHIVED, - ])('allows the existing experiment menu state %s', (status) => { - expect(localDeletionReason('experiments', { id: 'a', stateOrStatus: status }, UserRole.CREATOR)).toBeUndefined(); + it('does not restrict experiment deletion by state', () => { + for (const status of [...Object.values(EXPERIMENT_STATE), undefined]) { + expect(localDeletionReason('experiments', { id: 'a', stateOrStatus: status }, UserRole.CREATOR)).toBeUndefined(); + } }); it.each([ - [ - 'experiments', - { id: 'a', stateOrStatus: EXPERIMENT_STATE.DRAFT }, - UserRole.ADMIN, - DeletionReasonCode.EXPERIMENT_STATE_UNSUPPORTED, - ], [ 'flags', { id: 'a', stateOrStatus: FEATURE_FLAG_STATUS.ENABLED }, UserRole.ADMIN, - DeletionReasonCode.FEATURE_FLAG_ENABLED, + BatchSelectionReasonCode.FEATURE_FLAG_ENABLED, ], - ['flags', { id: 'a' }, UserRole.ADMIN, DeletionReasonCode.FEATURE_FLAG_STATUS_UNSUPPORTED], + ['flags', { id: 'a' }, UserRole.ADMIN, BatchSelectionReasonCode.FEATURE_FLAG_STATUS_UNSUPPORTED], [ 'segments', { ...item('a'), stateOrStatus: SEGMENT_STATUS.USED }, UserRole.ADMIN, - DeletionReasonCode.SEGMENT_IN_USE, + BatchSelectionReasonCode.SEGMENT_IN_USE, ], - ['segments', item('a'), UserRole.READER, DeletionReasonCode.MISSING_PERMISSION], + ['segments', item('a'), UserRole.READER, BatchSelectionReasonCode.MISSING_PERMISSION], ['segments', item('a'), UserRole.USER_MANAGER, undefined], [ 'flags', { id: 'a', stateOrStatus: FEATURE_FLAG_STATUS.DISABLED }, UserRole.USER_MANAGER, - DeletionReasonCode.MISSING_PERMISSION, + BatchSelectionReasonCode.MISSING_PERMISSION, ], ])( 'applies entity-specific permissions and status rules for %s', - (entity: BatchDeleteEntity, selected: RootSelectionItem, role: UserRole, reason: DeletionReasonCode) => { + (entity: BatchDeleteEntity, selected: RootSelectionItem, role: UserRole, reason: BatchSelectionReasonCode) => { expect(localDeletionReason(entity, selected, role)).toBe(reason); } ); diff --git a/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.helpers.ts b/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.helpers.ts index b4a020304..56672f589 100644 --- a/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.helpers.ts +++ b/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.helpers.ts @@ -6,11 +6,9 @@ import { FEATURE_FLAG_STATUS, SEGMENT_STATUS, SEGMENT_TYPE, - getExperimentDeletionReason, - getFlagDeletionReason, hasBatchDeletePermission, } from 'upgrade_types'; -import { RootBatchState, RootSelectionItem, isBatchBusy } from './batch-actions.models'; +import { BatchSelectionReasonCode, RootBatchState, RootSelectionItem, isBatchBusy } from './batch-actions.models'; export function selectionItem(row: { id?: string; @@ -22,21 +20,28 @@ export function selectionItem(row: { return { id: row.id, name: row.name, stateOrStatus: row.state || row.status, segmentType: row.type }; } +function getFlagDeletionReason(status: FEATURE_FLAG_STATUS): BatchSelectionReasonCode | undefined { + return status === FEATURE_FLAG_STATUS.ENABLED + ? BatchSelectionReasonCode.FEATURE_FLAG_ENABLED + : [FEATURE_FLAG_STATUS.DISABLED, FEATURE_FLAG_STATUS.ARCHIVED].includes(status) + ? undefined + : BatchSelectionReasonCode.FEATURE_FLAG_STATUS_UNSUPPORTED; +} + export function localDeletionReason( entity: BatchDeleteEntity, item: RootSelectionItem, role: RootBatchState['role'] -): DeletionReasonCode | undefined { - if (!hasBatchDeletePermission(role, entity)) return DeletionReasonCode.MISSING_PERMISSION; - if (item.reasonCode) return item.reasonCode; - if (entity === 'experiments') return getExperimentDeletionReason(item.stateOrStatus as EXPERIMENT_STATE); +): BatchSelectionReasonCode | undefined { + if (!hasBatchDeletePermission(role, entity)) return BatchSelectionReasonCode.MISSING_PERMISSION; + if (entity === 'experiments') return undefined; if (entity === 'flags') return getFlagDeletionReason(item.stateOrStatus as FEATURE_FLAG_STATUS); - if (item.segmentType !== SEGMENT_TYPE.PUBLIC) return DeletionReasonCode.PROTECTED_SEGMENT_TYPE; + if (item.segmentType !== SEGMENT_TYPE.PUBLIC) return BatchSelectionReasonCode.PROTECTED_SEGMENT_TYPE; return item.stateOrStatus === SEGMENT_STATUS.UNUSED ? undefined : item.stateOrStatus === SEGMENT_STATUS.USED - ? DeletionReasonCode.SEGMENT_IN_USE - : DeletionReasonCode.ELIGIBILITY_UNAVAILABLE; + ? BatchSelectionReasonCode.SEGMENT_IN_USE + : BatchSelectionReasonCode.ELIGIBILITY_UNAVAILABLE; } export function selectionView(state: RootBatchState, entity: BatchDeleteEntity) { @@ -61,7 +66,7 @@ export const confirmedRemovedIds = (result?: BatchDeleteResult) => []; export function validateBatchResponse(result: BatchDeleteResult, ids: string[]): BatchDeleteResult { - const outcomes = ['deleted', 'not_found', 'ineligible', 'failed', 'unknown', 'not_attempted']; + const outcomes = ['deleted', 'not_found', 'failed', 'unknown', 'not_attempted']; if ( !result || !['rejected', 'executed'].includes(result.phase) || @@ -82,7 +87,7 @@ export function batchResultCounts(state: RootBatchState) { deleted: results.filter((item) => item.outcome === 'deleted').length, absent: results.filter((item) => item.outcome === 'not_found').length, uncertain: results.some((item) => item.outcome === 'unknown'), - failed: results.filter((item) => ['failed', 'ineligible'].includes(item.outcome)).length, + failed: results.filter((item) => item.outcome === 'failed').length, notAttempted: results.filter((item) => item.outcome === 'not_attempted').length, hasErrors: results.some((item) => item.outcome !== 'deleted' || item.reasonCode), postDeleteFailed: results.some((item) => item.reasonCode === DeletionReasonCode.POST_DELETE_FAILED), diff --git a/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.integration.spec.ts b/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.integration.spec.ts index 566ddeb6c..e2def4582 100644 --- a/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.integration.spec.ts +++ b/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.integration.spec.ts @@ -4,7 +4,6 @@ import { Actions, getEffectsMetadata } from '@ngrx/effects'; import { Action, ScannedActionsSubject, Store, StoreModule } from '@ngrx/store'; import { Subject, Subscription, of, throwError } from 'rxjs'; import { - DeletionReasonCode, EXPERIMENT_STATE, FEATURE_FLAG_STATUS, FLAG_SEARCH_KEY, @@ -28,7 +27,7 @@ import { FeatureFlagRootSectionCardComponent } from '../../features/dashboard/fe import { SegmentsEffects } from '../segments/store/segments.effects'; import { actionLogoutStart, actionSetUserInfo } from '../auth/store/auth.actions'; import { batchResultCounts, selectionItem, selectionView } from './batch-actions.helpers'; -import { RootBatchState, newBatchRequestId } from './batch-actions.models'; +import { BatchSelectionReasonCode, RootBatchState, newBatchRequestId } from './batch-actions.models'; const fixtures = [ { @@ -297,24 +296,25 @@ describe.each(fixtures)('$entity batch store/effects integration', (config) => { expect(data.batchDelete).not.toHaveBeenCalled(); }); - it('uses updated page data to block a hidden restriction without an eligibility request', () => { - selectRows(); - const blocked = { - ...rows[2], - state: config.entity === 'experiments' ? EXPERIMENT_STATE.DRAFT : undefined, - status: config.entity === 'segments' ? SEGMENT_STATUS.USED : FEATURE_FLAG_STATUS.ENABLED, - }; - data[config.fetchMethod].mockReturnValueOnce(of(page([rows[0], rows[1], blocked]))); - store.dispatch(config.fetch({ fromStarting: true })); - data[config.fetchMethod].mockReturnValueOnce(of(page([rows[0]]))); - store.dispatch(config.fetch({ fromStarting: true })); - store.dispatch(actions.prepareConfirmation({ operationId: newBatchRequestId() })); - expect(batch().confirmation).toBeNull(); - expect(Object.keys(batch().selectedById)).toHaveLength(3); - expect(selectionView(batch(), config.entity).canRequestConfirmation).toBe(false); + if (config.entity !== 'experiments') { + it('uses updated page data to block a hidden restriction without an eligibility request', () => { + selectRows(); + const blocked = { + ...rows[2], + status: config.entity === 'segments' ? SEGMENT_STATUS.USED : FEATURE_FLAG_STATUS.ENABLED, + }; + data[config.fetchMethod].mockReturnValueOnce(of(page([rows[0], rows[1], blocked]))); + store.dispatch(config.fetch({ fromStarting: true })); + data[config.fetchMethod].mockReturnValueOnce(of(page([rows[0]]))); + store.dispatch(config.fetch({ fromStarting: true })); + store.dispatch(actions.prepareConfirmation({ operationId: newBatchRequestId() })); + expect(batch().confirmation).toBeNull(); + expect(Object.keys(batch().selectedById)).toHaveLength(3); + expect(selectionView(batch(), config.entity).canRequestConfirmation).toBe(false); - expect(data.batchDelete).not.toHaveBeenCalled(); - }); + expect(data.batchDelete).not.toHaveBeenCalled(); + }); + } it('retains cached selections until the delete response establishes an absence', () => { selectRows(); @@ -404,60 +404,6 @@ describe.each(fixtures)('$entity batch store/effects integration', (config) => { } ); - it.each(['visible', 'hidden', 'refresh failure'])( - 'retains the server restriction until fresh row data arrives (%s)', - (mode) => { - selectRows(); - if (mode === 'hidden') { - data[config.fetchMethod].mockReturnValueOnce(of(page([rows[0], rows[2]]))); - store.dispatch(config.fetch({ fromStarting: true })); - } - const snapshot = prepare(); - expect(snapshot.items.map(({ id }) => id)).toEqual(rows.map(({ id }) => id)); - const blocked = { - ...rows[1], - state: config.entity === 'experiments' ? EXPERIMENT_STATE.DRAFT : undefined, - status: config.entity === 'segments' ? SEGMENT_STATUS.USED : FEATURE_FLAG_STATUS.ENABLED, - }; - const reasonCode = { - experiments: DeletionReasonCode.EXPERIMENT_STATE_UNSUPPORTED, - flags: DeletionReasonCode.FEATURE_FLAG_ENABLED, - segments: DeletionReasonCode.SEGMENT_IN_USE, - }[config.entity]; - data[config.fetchMethod].mockReturnValueOnce( - mode === 'refresh failure' - ? throwError(() => new Error('refresh unavailable')) - : of(page(mode === 'hidden' ? [] : [blocked])) - ); - store.dispatch(actions.batchDeleteRequested({ snapshot })); - response.next({ - phase: 'executed', - results: rows.map(({ id }, index) => ({ - id, - outcome: index === 1 ? 'ineligible' : 'deleted', - reasonCode: index === 1 ? reasonCode : undefined, - })), - }); - response.complete(); - expect(Object.keys(batch().selectedById)).toEqual([rows[1].id]); - expect(currentRows().map((row) => row.id)).toEqual(mode === 'hidden' ? [] : [rows[1].id]); - expect(selectionView(batch(), config.entity)).toMatchObject({ canRequestConfirmation: false, reasonCode }); - store.dispatch(actions.prepareConfirmation({ operationId: 'retry-before-refresh' })); - expect(batch().confirmation).toBeNull(); - const noun = { experiments: 'experiments', flags: 'feature flags', segments: 'segments' }[config.entity]; - expect(notifications.showWarning).toHaveBeenCalledTimes(1); - expect(notifications.showWarning).toHaveBeenCalledWith(`2 ${noun} deleted. 1 item could not be deleted.`); - expect(data.batchDelete).toHaveBeenCalledTimes(1); - - data[config.fetchMethod].mockReturnValueOnce(of(page([rows[1]]))); - store.dispatch(config.fetch({ fromStarting: true })); - expect(selectionView(batch(), config.entity)).toMatchObject({ - canRequestConfirmation: true, - reasonCode: undefined, - }); - } - ); - it('keeps the displayed confirmation snapshot when updated rows arrive or focus returns', fakeAsync(() => { selectRows(2); const snapshot = prepare(); @@ -609,20 +555,6 @@ describe.each(fixtures)('$entity batch store/effects integration', (config) => { }); } - it('retains every selection when the server reports all items as ineligible', () => { - selectRows(); - const snapshot = prepare(); - store.dispatch(actions.batchDeleteRequested({ snapshot })); - response.next({ - phase: 'rejected', - results: rows.map(({ id }) => ({ id, outcome: 'ineligible' })), - }); - expect(currentRows()).toHaveLength(3); - expect(Object.keys(batch().selectedById)).toHaveLength(3); - expect(batch().operation.result.phase).toBe('rejected'); - expect(notifications.showWarning).toHaveBeenCalledTimes(1); - }); - it('keeps confirmed deletion when the post-commit cleanup or list refresh fails', () => { selectRows(1); const snapshot = prepare(); @@ -722,6 +654,6 @@ describe.each(fixtures)('$entity batch store/effects integration', (config) => { store.dispatch(actionSetUserInfo({ user: { email: 'review@example.com', role: UserRole.READER } })); expect(Object.keys(batch().selectedById)).toHaveLength(3); expect(batch().confirmation).toBeNull(); - expect(selectionView(batch(), config.entity).reasonCode).toBe(DeletionReasonCode.MISSING_PERMISSION); + expect(selectionView(batch(), config.entity).reasonCode).toBe(BatchSelectionReasonCode.MISSING_PERMISSION); }); }); diff --git a/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.models.ts b/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.models.ts index 991961aaa..7571acc15 100644 --- a/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.models.ts +++ b/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.models.ts @@ -1,6 +1,5 @@ import { BatchDeleteResult, - DeletionReasonCode, EXPERIMENT_STATE, FEATURE_FLAG_STATUS, SEGMENT_STATUS, @@ -8,12 +7,20 @@ import { UserRole, } from 'upgrade_types'; +export enum BatchSelectionReasonCode { + MISSING_PERMISSION = 'missing_permission', + FEATURE_FLAG_ENABLED = 'feature_flag_enabled', + FEATURE_FLAG_STATUS_UNSUPPORTED = 'feature_flag_status_unsupported', + SEGMENT_IN_USE = 'segment_in_use', + PROTECTED_SEGMENT_TYPE = 'protected_segment_type', + ELIGIBILITY_UNAVAILABLE = 'eligibility_unavailable', +} + export interface RootSelectionItem { id: string; name?: string; stateOrStatus?: EXPERIMENT_STATE | FEATURE_FLAG_STATUS | SEGMENT_STATUS; segmentType?: SEGMENT_TYPE; - reasonCode?: DeletionReasonCode; } export interface BatchDeleteSnapshot { diff --git a/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.reducer.ts b/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.reducer.ts index 7e994691a..5a1626e86 100644 --- a/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.reducer.ts +++ b/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.reducer.ts @@ -1,5 +1,5 @@ import { Action } from '@ngrx/store'; -import { BatchDeleteEntity, DeletionReasonCode, SEGMENT_TYPE, hasBatchDeletePermission } from 'upgrade_types'; +import { BatchDeleteEntity, SEGMENT_TYPE, hasBatchDeletePermission } from 'upgrade_types'; import * as auth from '../auth/store/auth.actions'; import { RootBatchActions } from './batch-actions.actions'; import { RootBatchState, RootSelectionItem, initialRootBatchState, isBatchBusy } from './batch-actions.models'; @@ -107,7 +107,7 @@ export function reduceRootBatch( if (matches(action, actions.prepareConfirmation)) { const selection = selectionView(state, entity); if (state.confirmation || !selection.canRequestConfirmation) return state; - // Use retained row metadata, including hidden selections. The delete endpoint validates current eligibility. + // Use retained row metadata, including hidden selections. UI availability is based on this cached data. return { ...state, confirmation: { @@ -144,19 +144,8 @@ export function reduceRootBatch( return state; if (matches(action, actions.batchDeleteCompleted)) { const next = removeConfirmed(state, confirmedRemovedIds(action.result)); - const selectedById = { ...next.selectedById }; - // Retain a known restriction even when the current-query refresh omits the item or fails. - for (const { id, outcome, reasonCode } of action.result.results) { - if (outcome === 'ineligible' && selectedById[id]) { - selectedById[id] = { - ...selectedById[id], - reasonCode: reasonCode || DeletionReasonCode.ELIGIBILITY_UNAVAILABLE, - }; - } - } return { ...invalidateSelection(next), - selectedById, operation: { ...state.operation, result: action.result, diff --git a/packages/frontend/projects/upgrade/src/app/features/dashboard/batch-delete-ui.spec.ts b/packages/frontend/projects/upgrade/src/app/features/dashboard/batch-delete-ui.spec.ts index 64df28233..076c332b7 100644 --- a/packages/frontend/projects/upgrade/src/app/features/dashboard/batch-delete-ui.spec.ts +++ b/packages/frontend/projects/upgrade/src/app/features/dashboard/batch-delete-ui.spec.ts @@ -468,26 +468,27 @@ describe.each(cases)('$entity root batch UI', (config) => { expect(Object.keys(batch().selectedById)).toHaveLength(2); }); - it('explains a hidden restriction only in the menu tooltip without inserting a status row', () => { - load([ - { - ...rows[0], - state: config.entity === 'experiments' ? EXPERIMENT_STATE.DRAFT : undefined, - status: config.entity === 'segments' ? SEGMENT_STATUS.USED : FEATURE_FLAG_STATUS.ENABLED, - }, - ]); - fixture.detectChanges(); - selectFirst(); - load([]); - fixture.detectChanges(); - fixture.componentInstance.batchUi.requestDelete(); - fixture.detectChanges(); - const trigger = fixture.nativeElement.querySelector('.section-card-menu-trigger') as HTMLButtonElement; - expect(trigger.disabled).toBe(true); - expect(trigger.parentElement.getAttribute('aria-label')).toContain('Deselect'); - expect(fixture.nativeElement.querySelector('.selection-status')).toBeNull(); - expect(fixture.nativeElement.textContent).not.toContain('Refresh selection status'); - expect(Object.keys(batch().selectedById)).toEqual([rows[0].id]); - expect(dialogs.openBatchDeleteModal).not.toHaveBeenCalled(); - }); + if (config.entity !== 'experiments') { + it('explains a hidden restriction only in the menu tooltip without inserting a status row', () => { + load([ + { + ...rows[0], + status: config.entity === 'segments' ? SEGMENT_STATUS.USED : FEATURE_FLAG_STATUS.ENABLED, + }, + ]); + fixture.detectChanges(); + selectFirst(); + load([]); + fixture.detectChanges(); + fixture.componentInstance.batchUi.requestDelete(); + fixture.detectChanges(); + const trigger = fixture.nativeElement.querySelector('.section-card-menu-trigger') as HTMLButtonElement; + expect(trigger.disabled).toBe(true); + expect(trigger.parentElement.getAttribute('aria-label')).toContain('Deselect'); + expect(fixture.nativeElement.querySelector('.selection-status')).toBeNull(); + expect(fixture.nativeElement.textContent).not.toContain('Refresh selection status'); + expect(Object.keys(batch().selectedById)).toEqual([rows[0].id]); + expect(dialogs.openBatchDeleteModal).not.toHaveBeenCalled(); + }); + } }); diff --git a/packages/frontend/projects/upgrade/src/assets/i18n/en.json b/packages/frontend/projects/upgrade/src/assets/i18n/en.json index 1fbf97623..61c087a2c 100644 --- a/packages/frontend/projects/upgrade/src/assets/i18n/en.json +++ b/packages/frontend/projects/upgrade/src/assets/i18n/en.json @@ -18,7 +18,6 @@ "batch-delete.dialog.segments.one": "Are you sure you want to delete 1 segment?", "batch-delete.dialog.segments.other": "Are you sure you want to delete {{count}} segments?", "batch-delete.reason.missing_permission": "You do not have permission to delete the selected items.", - "batch-delete.reason.experiment_state_unsupported": "A selected experiment cannot be deleted in its current state. Deselect it to enable deletion.", "batch-delete.reason.feature_flag_enabled": "A selected feature flag is enabled. Deselect it or disable it before deleting.", "batch-delete.reason.feature_flag_status_unsupported": "A selected feature flag cannot be deleted in its current state. Deselect it to enable deletion.", "batch-delete.reason.segment_in_use": "Some selected segments are in use. Deselect them to enable deletion.", diff --git a/packages/types/src/BatchActions/index.ts b/packages/types/src/BatchActions/index.ts index 2ab945ae4..b93f4b56a 100644 --- a/packages/types/src/BatchActions/index.ts +++ b/packages/types/src/BatchActions/index.ts @@ -1,9 +1,4 @@ -import { - EXPERIMENT_STATE, - EXPERIMENT_STATE_DISPLAY_NAME_OVERRIDES, - FEATURE_FLAG_STATUS, - UserRole, -} from '../Experiment/enums'; +import { UserRole } from '../Experiment/enums'; export interface BatchEntityIdsRequest { ids: string[]; @@ -11,13 +6,6 @@ export interface BatchEntityIdsRequest { export enum DeletionReasonCode { NOT_FOUND = 'not_found', - MISSING_PERMISSION = 'missing_permission', - EXPERIMENT_STATE_UNSUPPORTED = 'experiment_state_unsupported', - FEATURE_FLAG_ENABLED = 'feature_flag_enabled', - FEATURE_FLAG_STATUS_UNSUPPORTED = 'feature_flag_status_unsupported', - SEGMENT_IN_USE = 'segment_in_use', - PROTECTED_SEGMENT_TYPE = 'protected_segment_type', - ELIGIBILITY_UNAVAILABLE = 'eligibility_unavailable', DELETE_FAILED = 'delete_failed', LOCK_TIMEOUT = 'lock_timeout', EXTERNAL_SYNC_FAILED = 'external_sync_failed', @@ -28,7 +16,7 @@ export enum DeletionReasonCode { export type BatchDeleteEntity = 'experiments' | 'flags' | 'segments'; -export type BatchDeleteItemOutcome = 'deleted' | 'not_found' | 'ineligible' | 'failed' | 'unknown' | 'not_attempted'; +export type BatchDeleteItemOutcome = 'deleted' | 'not_found' | 'failed' | 'unknown' | 'not_attempted'; export interface BatchDeleteItemResult { id: string; @@ -46,27 +34,3 @@ export function hasBatchDeletePermission(role: UserRole, entity: BatchDeleteEnti role === UserRole.ADMIN || role === UserRole.CREATOR || (entity === 'segments' && role === UserRole.USER_MANAGER) ); } - -function getExperimentDeletionState(state: EXPERIMENT_STATE): EXPERIMENT_STATE { - return EXPERIMENT_STATE_DISPLAY_NAME_OVERRIDES[state] || state; -} - -export function getExperimentDeletionReason(state: EXPERIMENT_STATE): DeletionReasonCode | undefined { - return [ - EXPERIMENT_STATE.INACTIVE, - EXPERIMENT_STATE.RUNNING, - EXPERIMENT_STATE.PAUSED, - EXPERIMENT_STATE.COMPLETED, - EXPERIMENT_STATE.ARCHIVED, - ].includes(getExperimentDeletionState(state)) - ? undefined - : DeletionReasonCode.EXPERIMENT_STATE_UNSUPPORTED; -} - -export function getFlagDeletionReason(status: FEATURE_FLAG_STATUS): DeletionReasonCode | undefined { - return status === FEATURE_FLAG_STATUS.ENABLED - ? DeletionReasonCode.FEATURE_FLAG_ENABLED - : [FEATURE_FLAG_STATUS.DISABLED, FEATURE_FLAG_STATUS.ARCHIVED].includes(status) - ? undefined - : DeletionReasonCode.FEATURE_FLAG_STATUS_UNSUPPORTED; -} diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index fe2864cf6..e4ff84e69 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -98,8 +98,6 @@ export { BatchEntityIdsRequest, BatchDeleteEntity, hasBatchDeletePermission, - getExperimentDeletionReason, - getFlagDeletionReason, DeletionReasonCode, BatchDeleteItemOutcome, BatchDeleteItemResult, From 7fb56a00437eb8e407186a7d4af1a8ad31862ee8 Mon Sep 17 00:00:00 2001 From: Zack Lee Date: Wed, 16 Sep 2026 13:29:02 -0400 Subject: [PATCH 31/61] fix: align experiment deletion rules and batch selection permissions --- .../batch-actions.helpers.spec.ts | 78 +++++-------- .../batch-actions/batch-actions.helpers.ts | 40 +++---- .../batch-actions.integration.spec.ts | 51 ++++----- .../batch-actions/batch-actions.models.ts | 17 +-- .../batch-actions/batch-actions.reducer.ts | 17 +-- .../store/experiments.selector.spec.ts | 39 +++++++ .../store/experiments.selectors.ts | 5 + .../dashboard/batch-delete-ui.spec.ts | 108 +++++++++++------- ...ent-root-section-card-table.component.html | 4 + ...iment-root-section-card-table.component.ts | 1 + ...xperiment-root-section-card.component.html | 1 + ...lag-root-section-card-table.component.html | 4 + ...-flag-root-section-card-table.component.ts | 1 + ...ture-flag-root-section-card.component.html | 1 + ...ent-root-section-card-table.component.html | 4 + ...gment-root-section-card-table.component.ts | 1 + .../segment-root-section-card.component.html | 1 + .../projects/upgrade/src/assets/i18n/en.json | 9 +- 18 files changed, 210 insertions(+), 172 deletions(-) diff --git a/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.helpers.spec.ts b/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.helpers.spec.ts index 9d44d4f2a..3626b22fb 100644 --- a/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.helpers.spec.ts +++ b/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.helpers.spec.ts @@ -1,11 +1,4 @@ -import { - BatchDeleteEntity, - EXPERIMENT_STATE, - FEATURE_FLAG_STATUS, - SEGMENT_STATUS, - SEGMENT_TYPE, - UserRole, -} from 'upgrade_types'; +import { BatchDeleteEntity, EXPERIMENT_STATE, FEATURE_FLAG_STATUS, SEGMENT_STATUS } from 'upgrade_types'; import { createBatchActions } from './batch-actions.actions'; import { localDeletionReason, selectionView, validateBatchResponse } from './batch-actions.helpers'; import { @@ -22,11 +15,9 @@ describe('Root selection rules', () => { id, name: id, stateOrStatus: SEGMENT_STATUS.UNUSED, - segmentType: SEGMENT_TYPE.PUBLIC, }); const state = (selected: string[], loaded: string[]): RootBatchState => ({ ...initialRootBatchState, - role: UserRole.ADMIN, selectedById: Object.fromEntries(selected.map((id) => [id, item(id)])), loadedIds: loaded, }); @@ -47,22 +38,17 @@ describe('Root selection rules', () => { } ); - it('excludes global/private segments and detail-only IDs from header selection', () => { - const current = state([], ['public', 'private', 'global']); + it('excludes IDs outside the loaded root rows from header selection', () => { + const current = state([], ['loaded']); const next = reduceRootBatch( current, actions.toggleHeader({ - items: [ - item('public'), - { ...item('private'), segmentType: SEGMENT_TYPE.PRIVATE }, - { ...item('global'), segmentType: SEGMENT_TYPE.GLOBAL_EXCLUDE }, - item('detail-only'), - ], + items: [item('loaded'), item('detail-only')], }), actions, 'segments' ); - expect(Object.keys(next.selectedById)).toEqual(['public']); + expect(Object.keys(next.selectedById)).toEqual(['loaded']); expect(current.selectedById).toEqual({}); }); @@ -75,40 +61,30 @@ describe('Root selection rules', () => { ).toEqual({}); }); - it('does not restrict experiment deletion by state', () => { - for (const status of [...Object.values(EXPERIMENT_STATE), undefined]) { - expect(localDeletionReason('experiments', { id: 'a', stateOrStatus: status }, UserRole.CREATOR)).toBeUndefined(); - } + it.each([ + [EXPERIMENT_STATE.DRAFT, true], + [EXPERIMENT_STATE.INACTIVE, true], + [EXPERIMENT_STATE.COMPLETED, true], + [EXPERIMENT_STATE.CANCELLED, true], + [EXPERIMENT_STATE.ARCHIVED, true], + [EXPERIMENT_STATE.PREVIEW, false], + [EXPERIMENT_STATE.SCHEDULED, false], + [EXPERIMENT_STATE.RUNNING, false], + [EXPERIMENT_STATE.ENROLLING, false], + [EXPERIMENT_STATE.PAUSED, false], + [EXPERIMENT_STATE.ENROLLMENT_COMPLETE, false], + ])('applies the experiment deletion policy to %s', (status: EXPERIMENT_STATE, allowed: boolean) => { + expect(localDeletionReason('experiments', { id: 'a', stateOrStatus: status })).toBe( + allowed ? undefined : BatchSelectionReasonCode.EXPERIMENT_ACTIVE + ); }); - it.each([ - [ - 'flags', - { id: 'a', stateOrStatus: FEATURE_FLAG_STATUS.ENABLED }, - UserRole.ADMIN, - BatchSelectionReasonCode.FEATURE_FLAG_ENABLED, - ], - ['flags', { id: 'a' }, UserRole.ADMIN, BatchSelectionReasonCode.FEATURE_FLAG_STATUS_UNSUPPORTED], - [ - 'segments', - { ...item('a'), stateOrStatus: SEGMENT_STATUS.USED }, - UserRole.ADMIN, - BatchSelectionReasonCode.SEGMENT_IN_USE, - ], - ['segments', item('a'), UserRole.READER, BatchSelectionReasonCode.MISSING_PERMISSION], - ['segments', item('a'), UserRole.USER_MANAGER, undefined], - [ - 'flags', - { id: 'a', stateOrStatus: FEATURE_FLAG_STATUS.DISABLED }, - UserRole.USER_MANAGER, - BatchSelectionReasonCode.MISSING_PERMISSION, - ], - ])( - 'applies entity-specific permissions and status rules for %s', - (entity: BatchDeleteEntity, selected: RootSelectionItem, role: UserRole, reason: BatchSelectionReasonCode) => { - expect(localDeletionReason(entity, selected, role)).toBe(reason); - } - ); + it.each<[BatchDeleteEntity, RootSelectionItem, BatchSelectionReasonCode]>([ + ['flags', { id: 'a', stateOrStatus: FEATURE_FLAG_STATUS.ENABLED }, BatchSelectionReasonCode.FEATURE_FLAG_ENABLED], + ['segments', { ...item('a'), stateOrStatus: SEGMENT_STATUS.USED }, BatchSelectionReasonCode.SEGMENT_USED], + ])('applies entity-specific status rules for %s', (entity, selected, reason) => { + expect(localDeletionReason(entity, selected)).toBe(reason); + }); it('treats an incomplete or duplicate deletion response as uncertain rather than silently removing rows', () => { expect(() => diff --git a/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.helpers.ts b/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.helpers.ts index 56672f589..f5506cfbe 100644 --- a/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.helpers.ts +++ b/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.helpers.ts @@ -3,10 +3,9 @@ import { BatchDeleteResult, DeletionReasonCode, EXPERIMENT_STATE, + EXPERIMENT_STATE_DISPLAY_NAME_OVERRIDES, FEATURE_FLAG_STATUS, SEGMENT_STATUS, - SEGMENT_TYPE, - hasBatchDeletePermission, } from 'upgrade_types'; import { BatchSelectionReasonCode, RootBatchState, RootSelectionItem, isBatchBusy } from './batch-actions.models'; @@ -15,40 +14,39 @@ export function selectionItem(row: { name?: string; state?: EXPERIMENT_STATE; status?: FEATURE_FLAG_STATUS | SEGMENT_STATUS; - type?: SEGMENT_TYPE; }): RootSelectionItem { - return { id: row.id, name: row.name, stateOrStatus: row.state || row.status, segmentType: row.type }; + return { id: row.id, name: row.name, stateOrStatus: row.state || row.status }; } -function getFlagDeletionReason(status: FEATURE_FLAG_STATUS): BatchSelectionReasonCode | undefined { - return status === FEATURE_FLAG_STATUS.ENABLED - ? BatchSelectionReasonCode.FEATURE_FLAG_ENABLED - : [FEATURE_FLAG_STATUS.DISABLED, FEATURE_FLAG_STATUS.ARCHIVED].includes(status) +export function getExperimentDeletionReason(state?: EXPERIMENT_STATE): BatchSelectionReasonCode | undefined { + const displayState = EXPERIMENT_STATE_DISPLAY_NAME_OVERRIDES[state] || state; + return [ + EXPERIMENT_STATE.DRAFT, + EXPERIMENT_STATE.INACTIVE, + EXPERIMENT_STATE.COMPLETED, + EXPERIMENT_STATE.ARCHIVED, + ].includes(displayState) ? undefined - : BatchSelectionReasonCode.FEATURE_FLAG_STATUS_UNSUPPORTED; + : BatchSelectionReasonCode.EXPERIMENT_ACTIVE; } export function localDeletionReason( entity: BatchDeleteEntity, - item: RootSelectionItem, - role: RootBatchState['role'] + item: RootSelectionItem ): BatchSelectionReasonCode | undefined { - if (!hasBatchDeletePermission(role, entity)) return BatchSelectionReasonCode.MISSING_PERMISSION; - if (entity === 'experiments') return undefined; - if (entity === 'flags') return getFlagDeletionReason(item.stateOrStatus as FEATURE_FLAG_STATUS); - if (item.segmentType !== SEGMENT_TYPE.PUBLIC) return BatchSelectionReasonCode.PROTECTED_SEGMENT_TYPE; - return item.stateOrStatus === SEGMENT_STATUS.UNUSED - ? undefined - : item.stateOrStatus === SEGMENT_STATUS.USED - ? BatchSelectionReasonCode.SEGMENT_IN_USE - : BatchSelectionReasonCode.ELIGIBILITY_UNAVAILABLE; + if (entity === 'experiments') return getExperimentDeletionReason(item.stateOrStatus as EXPERIMENT_STATE); + if (entity === 'flags') + return item.stateOrStatus === FEATURE_FLAG_STATUS.ENABLED + ? BatchSelectionReasonCode.FEATURE_FLAG_ENABLED + : undefined; + return item.stateOrStatus === SEGMENT_STATUS.USED ? BatchSelectionReasonCode.SEGMENT_USED : undefined; } export function selectionView(state: RootBatchState, entity: BatchDeleteEntity) { const items = Object.values(state.selectedById); const loaded = new Set(state.loadedIds); const checked = loaded.size > 0 && [...loaded].every((id) => !!state.selectedById[id]); - const reasons = items.map((item) => localDeletionReason(entity, item, state.role)).filter(Boolean); + const reasons = items.map((item) => localDeletionReason(entity, item)).filter(Boolean); return { items, selectedCount: items.length, diff --git a/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.integration.spec.ts b/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.integration.spec.ts index e2def4582..f6475a5fa 100644 --- a/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.integration.spec.ts +++ b/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.integration.spec.ts @@ -8,7 +8,6 @@ import { FEATURE_FLAG_STATUS, FLAG_SEARCH_KEY, SEGMENT_STATUS, - SEGMENT_TYPE, SORT_AS_DIRECTION, UserRole, } from 'upgrade_types'; @@ -27,7 +26,7 @@ import { FeatureFlagRootSectionCardComponent } from '../../features/dashboard/fe import { SegmentsEffects } from '../segments/store/segments.effects'; import { actionLogoutStart, actionSetUserInfo } from '../auth/store/auth.actions'; import { batchResultCounts, selectionItem, selectionView } from './batch-actions.helpers'; -import { BatchSelectionReasonCode, RootBatchState, newBatchRequestId } from './batch-actions.models'; +import { RootBatchState, newBatchRequestId } from './batch-actions.models'; const fixtures = [ { @@ -76,9 +75,8 @@ describe.each(fixtures)('$entity batch store/effects integration', (config) => { const rows = ['a', 'b', 'c'].map((name, index) => ({ id: `11111111-2222-4333-8444-${String(index + 1).padStart(12, '0')}`, name, - state: config.entity === 'experiments' ? EXPERIMENT_STATE.ENROLLING : undefined, + state: config.entity === 'experiments' ? EXPERIMENT_STATE.INACTIVE : undefined, status: config.entity === 'segments' ? SEGMENT_STATUS.UNUSED : FEATURE_FLAG_STATUS.DISABLED, - type: SEGMENT_TYPE.PUBLIC, })); const batch = (): RootBatchState => state[config.key].rootBatch; const currentRows = () => state[config.key][config.entity === 'flags' ? 'featureFlags' : config.entity]; @@ -296,25 +294,24 @@ describe.each(fixtures)('$entity batch store/effects integration', (config) => { expect(data.batchDelete).not.toHaveBeenCalled(); }); - if (config.entity !== 'experiments') { - it('uses updated page data to block a hidden restriction without an eligibility request', () => { - selectRows(); - const blocked = { - ...rows[2], - status: config.entity === 'segments' ? SEGMENT_STATUS.USED : FEATURE_FLAG_STATUS.ENABLED, - }; - data[config.fetchMethod].mockReturnValueOnce(of(page([rows[0], rows[1], blocked]))); - store.dispatch(config.fetch({ fromStarting: true })); - data[config.fetchMethod].mockReturnValueOnce(of(page([rows[0]]))); - store.dispatch(config.fetch({ fromStarting: true })); - store.dispatch(actions.prepareConfirmation({ operationId: newBatchRequestId() })); - expect(batch().confirmation).toBeNull(); - expect(Object.keys(batch().selectedById)).toHaveLength(3); - expect(selectionView(batch(), config.entity).canRequestConfirmation).toBe(false); + it('uses updated page data to block a hidden restriction without an eligibility request', () => { + selectRows(); + const blocked = { + ...rows[2], + state: config.entity === 'experiments' ? EXPERIMENT_STATE.RUNNING : undefined, + status: config.entity === 'segments' ? SEGMENT_STATUS.USED : FEATURE_FLAG_STATUS.ENABLED, + }; + data[config.fetchMethod].mockReturnValueOnce(of(page([rows[0], rows[1], blocked]))); + store.dispatch(config.fetch({ fromStarting: true })); + data[config.fetchMethod].mockReturnValueOnce(of(page([rows[0]]))); + store.dispatch(config.fetch({ fromStarting: true })); + store.dispatch(actions.prepareConfirmation({ operationId: newBatchRequestId() })); + expect(batch().confirmation).toBeNull(); + expect(Object.keys(batch().selectedById)).toHaveLength(3); + expect(selectionView(batch(), config.entity).canRequestConfirmation).toBe(false); - expect(data.batchDelete).not.toHaveBeenCalled(); - }); - } + expect(data.batchDelete).not.toHaveBeenCalled(); + }); it('retains cached selections until the delete response establishes an absence', () => { selectRows(); @@ -648,12 +645,14 @@ describe.each(fixtures)('$entity batch store/effects integration', (config) => { expect(notifications.showSuccess).not.toHaveBeenCalled(); }); - it('retains selection but invalidates confirmation when the role changes', () => { + it('clears selection and prevents submission of the old confirmation when the role changes', () => { selectRows(); - prepare(); + const snapshot = prepare(); store.dispatch(actionSetUserInfo({ user: { email: 'review@example.com', role: UserRole.READER } })); - expect(Object.keys(batch().selectedById)).toHaveLength(3); + expect(Object.keys(batch().selectedById)).toHaveLength(0); expect(batch().confirmation).toBeNull(); - expect(selectionView(batch(), config.entity).reasonCode).toBe(BatchSelectionReasonCode.MISSING_PERMISSION); + store.dispatch(actions.batchDeleteRequested({ snapshot })); + expect(data.batchDelete).not.toHaveBeenCalled(); + expect(batch().operation).toBeNull(); }); }); diff --git a/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.models.ts b/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.models.ts index 7571acc15..f9a6bb6af 100644 --- a/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.models.ts +++ b/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.models.ts @@ -1,26 +1,15 @@ -import { - BatchDeleteResult, - EXPERIMENT_STATE, - FEATURE_FLAG_STATUS, - SEGMENT_STATUS, - SEGMENT_TYPE, - UserRole, -} from 'upgrade_types'; +import { BatchDeleteResult, EXPERIMENT_STATE, FEATURE_FLAG_STATUS, SEGMENT_STATUS, UserRole } from 'upgrade_types'; export enum BatchSelectionReasonCode { - MISSING_PERMISSION = 'missing_permission', + EXPERIMENT_ACTIVE = 'experiment_active', FEATURE_FLAG_ENABLED = 'feature_flag_enabled', - FEATURE_FLAG_STATUS_UNSUPPORTED = 'feature_flag_status_unsupported', - SEGMENT_IN_USE = 'segment_in_use', - PROTECTED_SEGMENT_TYPE = 'protected_segment_type', - ELIGIBILITY_UNAVAILABLE = 'eligibility_unavailable', + SEGMENT_USED = 'segment_used', } export interface RootSelectionItem { id: string; name?: string; stateOrStatus?: EXPERIMENT_STATE | FEATURE_FLAG_STATUS | SEGMENT_STATUS; - segmentType?: SEGMENT_TYPE; } export interface BatchDeleteSnapshot { diff --git a/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.reducer.ts b/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.reducer.ts index 5a1626e86..f4a27ab68 100644 --- a/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.reducer.ts +++ b/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.reducer.ts @@ -1,5 +1,5 @@ import { Action } from '@ngrx/store'; -import { BatchDeleteEntity, SEGMENT_TYPE, hasBatchDeletePermission } from 'upgrade_types'; +import { BatchDeleteEntity } from 'upgrade_types'; import * as auth from '../auth/store/auth.actions'; import { RootBatchActions } from './batch-actions.actions'; import { RootBatchState, RootSelectionItem, initialRootBatchState, isBatchBusy } from './batch-actions.models'; @@ -65,7 +65,7 @@ export function reduceRootBatch( const email = action.user?.email || null; const role = action.user?.role || null; if (state.userEmail !== email) return { ...initialRootBatchState, userEmail: email, role }; - return state.role === role ? state : { ...invalidateSelection(state), role }; + return state.role === role ? state : { ...invalidateSelection(state), selectedById: {}, role }; } if (matches(action, actions.listRequested)) return { @@ -94,10 +94,7 @@ export function reduceRootBatch( : []; for (const item of items) { if (selectedById[item.id] && matches(action, actions.toggleRow)) delete selectedById[item.id]; - else if ( - state.loadedIds.includes(item.id) && - (entity !== 'segments' || item.segmentType === SEGMENT_TYPE.PUBLIC) - ) { + else if (state.loadedIds.includes(item.id)) { selectedById[item.id] = { ...item }; } } @@ -120,13 +117,7 @@ export function reduceRootBatch( return isBatchBusy(state) ? state : { ...state, confirmation: null }; if (matches(action, actions.batchDeleteRequested)) { const snapshot = state.confirmation; - if ( - isBatchBusy(state) || - !snapshot || - snapshot.operationId !== action.snapshot.operationId || - !hasBatchDeletePermission(state.role, entity) - ) - return state; + if (isBatchBusy(state) || !snapshot || snapshot.operationId !== action.snapshot.operationId) return state; return { ...state, listRequestId: null, diff --git a/packages/frontend/projects/upgrade/src/app/core/experiments/store/experiments.selector.spec.ts b/packages/frontend/projects/upgrade/src/app/core/experiments/store/experiments.selector.spec.ts index cbe514a3a..22e595ec8 100644 --- a/packages/frontend/projects/upgrade/src/app/core/experiments/store/experiments.selector.spec.ts +++ b/packages/frontend/projects/upgrade/src/app/core/experiments/store/experiments.selector.spec.ts @@ -9,6 +9,7 @@ import { ASSIGNMENT_UNIT, CONSISTENCY_RULE, CONDITION_ORDER, + EXPERIMENT_DETAILS_PAGE_ACTIONS, } from './experiments.model'; import { initialState } from './experiments.reducer'; import { @@ -35,6 +36,7 @@ import { selectRewardsDataForSelectedExperiment, selectIsLoadingRewardsSummary, selectExperimentDetailsPageError, + selectExperimentMenuItems, } from './experiments.selectors'; import { PAGE_ERROR_TYPE } from '@shared-component-lib/common-page-error/common-page-error.model'; @@ -928,6 +930,43 @@ describe('Experiments Selectors', () => { }); }); + describe('#selectExperimentMenuItems', () => { + it.each([ + [EXPERIMENT_STATE.DRAFT, false, true, true], + [EXPERIMENT_STATE.INACTIVE, false, true, false], + [EXPERIMENT_STATE.COMPLETED, false, false, false], + [EXPERIMENT_STATE.CANCELLED, false, true, true], + [EXPERIMENT_STATE.ARCHIVED, false, true, false], + [EXPERIMENT_STATE.PREVIEW, true, true, true], + [EXPERIMENT_STATE.SCHEDULED, true, true, true], + [EXPERIMENT_STATE.RUNNING, true, true, false], + [EXPERIMENT_STATE.ENROLLING, true, true, true], + [EXPERIMENT_STATE.PAUSED, true, true, false], + [EXPERIMENT_STATE.ENROLLMENT_COMPLETE, true, true, true], + [undefined, true, true, true], + ])( + 'applies Delete rules in %s while preserving other menu actions', + (state: EXPERIMENT_STATE, deleteDisabled: boolean, archiveDisabled: boolean, otherDisabled: boolean) => { + const items = selectExperimentMenuItems.projector({ ...mockState.experiments[0], state }); + expect(items.find((item) => item.action === EXPERIMENT_DETAILS_PAGE_ACTIONS.DELETE).disabled).toBe( + deleteDisabled + ); + expect(items.find((item) => item.action === EXPERIMENT_DETAILS_PAGE_ACTIONS.ARCHIVE).disabled).toBe( + archiveDisabled + ); + for (const action of [ + EXPERIMENT_DETAILS_PAGE_ACTIONS.EDIT, + EXPERIMENT_DETAILS_PAGE_ACTIONS.DUPLICATE, + EXPERIMENT_DETAILS_PAGE_ACTIONS.EXPORT_DESIGN, + EXPERIMENT_DETAILS_PAGE_ACTIONS.EMAIL_DATA, + EXPERIMENT_DETAILS_PAGE_ACTIONS.EXPORT_STATE_CHANGE_LOGS, + ]) { + expect(items.find((item) => item.action === action).disabled).toBe(otherDisabled); + } + } + ); + }); + describe('#selectExperimentDetailsPageError', () => { const detailsPageError = { entityId: 'abc123', errorType: PAGE_ERROR_TYPE.NOT_FOUND }; const routerStateFor = (experimentId: string) => ({ state: { params: { experimentId } } } as any); diff --git a/packages/frontend/projects/upgrade/src/app/core/experiments/store/experiments.selectors.ts b/packages/frontend/projects/upgrade/src/app/core/experiments/store/experiments.selectors.ts index 409826237..4212985ef 100644 --- a/packages/frontend/projects/upgrade/src/app/core/experiments/store/experiments.selectors.ts +++ b/packages/frontend/projects/upgrade/src/app/core/experiments/store/experiments.selectors.ts @@ -29,6 +29,7 @@ import { determineWeightingMethod, isWeightSumValid } from '../condition-helper. import { formatTSConfigurablePolicyParamDetails } from '../mooclet-helper.service'; import { KeyValueFormat } from '@shared-component-lib/common-section-card-overview-details/common-section-card-overview-details.component'; import { DetailsPageError } from '@shared-component-lib/common-page-error/common-page-error.model'; +import { getExperimentDeletionReason } from '../../batch-actions/batch-actions.helpers'; export const selectExperimentState = createFeatureSelector('experiments'); @@ -532,6 +533,10 @@ const isMenuItemDisabled = (action: EXPERIMENT_DETAILS_PAGE_ACTIONS, state?: EXP return true; // No state = disabled } + if (action === EXPERIMENT_DETAILS_PAGE_ACTIONS.DELETE) { + return !!getExperimentDeletionReason(state); + } + // Archive only enabled when COMPLETED if (action === EXPERIMENT_DETAILS_PAGE_ACTIONS.ARCHIVE) { return state !== EXPERIMENT_STATE.COMPLETED; diff --git a/packages/frontend/projects/upgrade/src/app/features/dashboard/batch-delete-ui.spec.ts b/packages/frontend/projects/upgrade/src/app/features/dashboard/batch-delete-ui.spec.ts index 076c332b7..a6a1d7f83 100644 --- a/packages/frontend/projects/upgrade/src/app/features/dashboard/batch-delete-ui.spec.ts +++ b/packages/frontend/projects/upgrade/src/app/features/dashboard/batch-delete-ui.spec.ts @@ -8,7 +8,7 @@ import { MatTooltip } from '@angular/material/tooltip'; import { Store, StoreModule } from '@ngrx/store'; import { TranslateModule, TranslateService } from '@ngx-translate/core'; import { BehaviorSubject, Subject, Subscription, of } from 'rxjs'; -import { EXPERIMENT_STATE, FEATURE_FLAG_STATUS, SEGMENT_STATUS, SEGMENT_TYPE, UserRole } from 'upgrade_types'; +import { EXPERIMENT_STATE, FEATURE_FLAG_STATUS, SEGMENT_STATUS, UserRole } from 'upgrade_types'; import { ExperimentRootSectionCardComponent } from './experiments/pages/experiment-root-page/experiment-root-page-content/experiment-root-section-card/experiment-root-section-card.component'; import { FeatureFlagRootSectionCardComponent } from './feature-flags/pages/feature-flag-root-page/feature-flag-root-page-content/feature-flag-root-section-card/feature-flag-root-section-card.component'; import { SegmentRootSectionCardComponent } from './segments/pages/segment-root-page/segment-root-page-content/segment-root-section-card/segment-root-section-card.component'; @@ -73,7 +73,6 @@ describe.each(cases)('$entity root batch UI', (config) => { tags: [], state: config.entity === 'experiments' ? EXPERIMENT_STATE.INACTIVE : undefined, status: config.entity === 'segments' ? SEGMENT_STATUS.UNUSED : FEATURE_FLAG_STATUS.DISABLED, - type: SEGMENT_TYPE.PUBLIC, })); const batch = () => state[config.key].rootBatch; const checkboxes = (): HTMLInputElement[] => [...fixture.nativeElement.querySelectorAll('input[type=checkbox]')]; @@ -110,9 +109,9 @@ describe.each(cases)('$entity root batch UI', (config) => { } beforeEach(async () => { permissions$ = new BehaviorSubject({ - experiments: { create: true }, - featureFlags: { create: true }, - segments: { create: true }, + experiments: { create: true, delete: true }, + featureFlags: { create: true, delete: true }, + segments: { create: true, delete: true }, }); global.IntersectionObserver = jest.fn(() => ({ observe: jest.fn(), disconnect: jest.fn() })) as any; await TestBed.configureTestingModule({ @@ -285,16 +284,21 @@ describe.each(cases)('$entity root batch UI', (config) => { expect(batch().confirmation).toBeNull(); })); - it('keeps a disabled menu discoverable to a Reader and allows clearing the selection', () => { + it('hides batch controls for a Reader while retaining the name and sort content', () => { + permissions$.next({ + experiments: { create: false, delete: false }, + featureFlags: { create: false, delete: false }, + segments: { create: false, delete: false }, + }); store.dispatch(actionSetUserInfo({ user: { email: 'test@example.com', role: UserRole.READER } })); - selectFirst(); - const trigger = fixture.nativeElement.querySelector('.section-card-menu-trigger') as HTMLButtonElement; - expect(trigger.disabled).toBe(true); - expect(trigger.parentElement.getAttribute('tabindex')).toBe('0'); - expect(trigger.parentElement.getAttribute('aria-label')).toContain('permission'); - checkboxes()[0].click(); fixture.detectChanges(); - expect(Object.keys(batch().selectedById)).toHaveLength(0); + expect(fixture.nativeElement.querySelectorAll('.batch-checkbox')).toHaveLength(0); + expect(fixture.nativeElement.querySelector('.section-card-menu-trigger')).toBeNull(); + const nameCells: HTMLElement[] = [...fixture.nativeElement.querySelectorAll('.batch-name-cell')]; + expect(nameCells).toHaveLength(rows.length + 1); + expect(nameCells.every((cell) => cell.children.length === 1)).toBe(true); + expect(nameCells[0].firstElementChild.classList.contains('batch-name-sort')).toBe(true); + rows.forEach((row, index) => expect(nameCells[index + 1].querySelector('a').textContent).toContain(row.name)); }); it('preserves selection across collapse and removes tag expansion only for confirmed removals', () => { @@ -398,14 +402,23 @@ describe.each(cases)('$entity root batch UI', (config) => { ); it('separates User Manager delete permission from create permission', () => { - permissions$.next({ experiments: { create: false }, featureFlags: { create: false }, segments: { create: false } }); + permissions$.next({ + experiments: { create: false, delete: false }, + featureFlags: { create: false, delete: false }, + segments: { create: false, delete: true }, + }); store.dispatch(actionSetUserInfo({ user: { email: 'test@example.com', role: UserRole.USER_MANAGER } })); fixture.detectChanges(); expect(fixture.nativeElement.querySelector('.section-card-menu-trigger')).toBeNull(); - selectFirst(); - const trigger = fixture.nativeElement.querySelector('.section-card-menu-trigger') as HTMLButtonElement; - expect(trigger).not.toBeNull(); - expect(trigger.disabled).toBe(config.entity !== 'segments'); + expect(fixture.nativeElement.querySelectorAll('.batch-checkbox')).toHaveLength( + config.entity === 'segments' ? rows.length + 1 : 0 + ); + if (config.entity === 'segments') { + selectFirst(); + const trigger = fixture.nativeElement.querySelector('.section-card-menu-trigger') as HTMLButtonElement; + expect(trigger).not.toBeNull(); + expect(trigger.disabled).toBe(false); + } }); it.each(['list', 'deletion'])('keeps the existing progress bar until both requests finish (%s first)', (first) => { @@ -468,27 +481,40 @@ describe.each(cases)('$entity root batch UI', (config) => { expect(Object.keys(batch().selectedById)).toHaveLength(2); }); - if (config.entity !== 'experiments') { - it('explains a hidden restriction only in the menu tooltip without inserting a status row', () => { - load([ - { - ...rows[0], - status: config.entity === 'segments' ? SEGMENT_STATUS.USED : FEATURE_FLAG_STATUS.ENABLED, - }, - ]); - fixture.detectChanges(); - selectFirst(); - load([]); - fixture.detectChanges(); - fixture.componentInstance.batchUi.requestDelete(); - fixture.detectChanges(); - const trigger = fixture.nativeElement.querySelector('.section-card-menu-trigger') as HTMLButtonElement; - expect(trigger.disabled).toBe(true); - expect(trigger.parentElement.getAttribute('aria-label')).toContain('Deselect'); - expect(fixture.nativeElement.querySelector('.selection-status')).toBeNull(); - expect(fixture.nativeElement.textContent).not.toContain('Refresh selection status'); - expect(Object.keys(batch().selectedById)).toEqual([rows[0].id]); - expect(dialogs.openBatchDeleteModal).not.toHaveBeenCalled(); - }); - } + it.each( + config.entity === 'experiments' + ? [EXPERIMENT_STATE.PREVIEW, EXPERIMENT_STATE.SCHEDULED, EXPERIMENT_STATE.RUNNING, EXPERIMENT_STATE.PAUSED] + : [config.entity === 'flags' ? FEATURE_FLAG_STATUS.ENABLED : SEGMENT_STATUS.USED] + )('blocks mixed selections with a hidden %s item using only the existing menu tooltip', (status) => { + load([ + { + ...rows[0], + state: config.entity === 'experiments' ? (status as EXPERIMENT_STATE) : undefined, + status: config.entity === 'segments' ? SEGMENT_STATUS.USED : FEATURE_FLAG_STATUS.ENABLED, + }, + rows[1], + ]); + fixture.detectChanges(); + checkboxes()[0].click(); + fixture.detectChanges(); + const trigger = fixture.nativeElement.querySelector('.section-card-menu-trigger') as HTMLButtonElement; + expect(trigger.disabled).toBe(true); + load([rows[1]]); + fixture.detectChanges(); + fixture.componentInstance.batchUi.requestDelete(); + fixture.detectChanges(); + expect(trigger.disabled).toBe(true); + expect(trigger.parentElement.getAttribute('aria-label')).toContain('Deselect'); + expect(fixture.nativeElement.querySelector('.selection-status')).toBeNull(); + expect(fixture.nativeElement.textContent).not.toContain('Refresh selection status'); + expect(Object.keys(batch().selectedById)).toEqual(rows.map(({ id }) => id)); + expect(batch().confirmation).toBeNull(); + expect(dialogs.openBatchDeleteModal).not.toHaveBeenCalled(); + + checkboxes()[0].click(); + fixture.detectChanges(); + selectFirst(); + fixture.componentInstance.batchUi.requestDelete(); + expect(dialogs.openBatchDeleteModal).toHaveBeenCalledTimes(1); + }); }); diff --git a/packages/frontend/projects/upgrade/src/app/features/dashboard/experiments/pages/experiment-root-page/experiment-root-page-content/experiment-root-section-card/experiment-root-section-card-table/experiment-root-section-card-table.component.html b/packages/frontend/projects/upgrade/src/app/features/dashboard/experiments/pages/experiment-root-page/experiment-root-page-content/experiment-root-section-card/experiment-root-section-card-table/experiment-root-section-card-table.component.html index 208f8e7d9..4c244927b 100644 --- a/packages/frontend/projects/upgrade/src/app/features/dashboard/experiments/pages/experiment-root-page/experiment-root-page-content/experiment-root-section-card/experiment-root-section-card-table/experiment-root-section-card-table.component.html +++ b/packages/frontend/projects/upgrade/src/app/features/dashboard/experiments/pages/experiment-root-page/experiment-root-page-content/experiment-root-section-card/experiment-root-section-card-table/experiment-root-section-card-table.component.html @@ -30,6 +30,7 @@ " >
+ @if (canSelect) { + }
+ + + diff --git a/packages/frontend/projects/upgrade/src/app/features/dashboard/experiments/pages/experiment-root-page/experiment-root-page-content/experiment-root-section-card/experiment-root-section-card-table/experiment-root-section-card-table.component.scss b/packages/frontend/projects/upgrade/src/app/features/dashboard/experiments/pages/experiment-root-page/experiment-root-page-content/experiment-root-section-card/experiment-root-section-card-table/experiment-root-section-card-table.component.scss index 3dd394c37..752c89ffa 100644 --- a/packages/frontend/projects/upgrade/src/app/features/dashboard/experiments/pages/experiment-root-page/experiment-root-page-content/experiment-root-section-card/experiment-root-section-card-table/experiment-root-section-card-table.component.scss +++ b/packages/frontend/projects/upgrade/src/app/features/dashboard/experiments/pages/experiment-root-page/experiment-root-page-content/experiment-root-section-card/experiment-root-section-card-table/experiment-root-section-card-table.component.scss @@ -63,14 +63,22 @@ } } + .batch-select-column { + width: 56px; + min-width: 56px; + padding: 0 8px; + } + + &.has-batch-controls .name-column { + width: calc(22% - 56px); + padding-left: 0; + padding-right: 16px; + } + .name-column { width: 22%; padding-left: 32px; - &:has(.batch-checkbox) { - padding-left: 8px; - } - .experiment-name { color: var(--black-2); } diff --git a/packages/frontend/projects/upgrade/src/app/features/dashboard/experiments/pages/experiment-root-page/experiment-root-page-content/experiment-root-section-card/experiment-root-section-card-table/experiment-root-section-card-table.component.ts b/packages/frontend/projects/upgrade/src/app/features/dashboard/experiments/pages/experiment-root-page/experiment-root-page-content/experiment-root-section-card/experiment-root-section-card-table/experiment-root-section-card-table.component.ts index 1055fed85..7ee381bb8 100644 --- a/packages/frontend/projects/upgrade/src/app/features/dashboard/experiments/pages/experiment-root-page/experiment-root-page-content/experiment-root-section-card/experiment-root-section-card-table/experiment-root-section-card-table.component.ts +++ b/packages/frontend/projects/upgrade/src/app/features/dashboard/experiments/pages/experiment-root-page/experiment-root-page-content/experiment-root-section-card/experiment-root-section-card-table/experiment-root-section-card-table.component.ts @@ -110,7 +110,7 @@ export class ExperimentRootSectionCardTableComponent implements AfterViewInit, O } get displayedColumns(): string[] { - return EXPERIMENT_ROOT_DISPLAYED_COLUMNS; + return this.canSelect ? ['select', ...EXPERIMENT_ROOT_DISPLAYED_COLUMNS] : EXPERIMENT_ROOT_DISPLAYED_COLUMNS; } get EXPERIMENT_TRANSLATION_KEYS() { diff --git a/packages/frontend/projects/upgrade/src/app/features/dashboard/experiments/pages/experiment-root-page/experiment-root-page-content/experiment-root-section-card/experiment-root-section-card.component.html b/packages/frontend/projects/upgrade/src/app/features/dashboard/experiments/pages/experiment-root-page/experiment-root-page-content/experiment-root-section-card/experiment-root-section-card.component.html index b572b9041..f72547e0c 100644 --- a/packages/frontend/projects/upgrade/src/app/features/dashboard/experiments/pages/experiment-root-page/experiment-root-page-content/experiment-root-section-card/experiment-root-section-card.component.html +++ b/packages/frontend/projects/upgrade/src/app/features/dashboard/experiments/pages/experiment-root-page/experiment-root-page-content/experiment-root-section-card/experiment-root-section-card.component.html @@ -33,7 +33,7 @@ @if (isSectionCardExpanded) { - @if (!(isInitialLoading$ | async) && !batchView?.selectedCount) { + @if (!(isInitialLoading$ | async)) {
diff --git a/packages/frontend/projects/upgrade/src/app/features/dashboard/feature-flags/pages/feature-flag-root-page/feature-flag-root-page-content/feature-flag-root-section-card/feature-flag-root-section-card-table/feature-flag-root-section-card-table.component.html b/packages/frontend/projects/upgrade/src/app/features/dashboard/feature-flags/pages/feature-flag-root-page/feature-flag-root-page-content/feature-flag-root-section-card/feature-flag-root-section-card-table/feature-flag-root-section-card-table.component.html index 7e5882ca9..8ded76261 100644 --- a/packages/frontend/projects/upgrade/src/app/features/dashboard/feature-flags/pages/feature-flag-root-page/feature-flag-root-page-content/feature-flag-root-section-card/feature-flag-root-section-card-table/feature-flag-root-section-card-table.component.html +++ b/packages/frontend/projects/upgrade/src/app/features/dashboard/feature-flags/pages/feature-flag-root-page/feature-flag-root-page-content/feature-flag-root-section-card/feature-flag-root-section-card-table/feature-flag-root-section-card-table.component.html @@ -9,94 +9,86 @@ [dataSource]="featureFlags" [trackBy]="trackByFlagId" [class.has-batch-selection]="selection?.selectedCount" + [class.has-batch-controls]="canSelect" [ngClass]="{ 'no-data': !featureFlags?.length }" matSort (matSortChange)="changeSorting($event)" [matSortActive]="flagSortKey$ | async" [matSortDirection]="flagSortAs$ | async | lowercase" > + + +
+ + + diff --git a/packages/frontend/projects/upgrade/src/app/features/dashboard/feature-flags/pages/feature-flag-root-page/feature-flag-root-page-content/feature-flag-root-section-card/feature-flag-root-section-card-table/feature-flag-root-section-card-table.component.scss b/packages/frontend/projects/upgrade/src/app/features/dashboard/feature-flags/pages/feature-flag-root-page/feature-flag-root-page-content/feature-flag-root-section-card/feature-flag-root-section-card-table/feature-flag-root-section-card-table.component.scss index 1a87dce29..f9ca5f8b5 100644 --- a/packages/frontend/projects/upgrade/src/app/features/dashboard/feature-flags/pages/feature-flag-root-page/feature-flag-root-page-content/feature-flag-root-section-card/feature-flag-root-section-card-table/feature-flag-root-section-card-table.component.scss +++ b/packages/frontend/projects/upgrade/src/app/features/dashboard/feature-flags/pages/feature-flag-root-page/feature-flag-root-page-content/feature-flag-root-section-card/feature-flag-root-section-card-table/feature-flag-root-section-card-table.component.scss @@ -63,14 +63,22 @@ } } + .batch-select-column { + width: 56px; + min-width: 56px; + padding: 0 8px; + } + + &.has-batch-controls .name-column { + width: calc(22% - 56px); + padding-left: 0; + padding-right: 16px; + } + .name-column { width: 22%; padding-left: 32px; - &:has(.batch-checkbox) { - padding-left: 8px; - } - .flag-name { color: var(--black-2); } diff --git a/packages/frontend/projects/upgrade/src/app/features/dashboard/feature-flags/pages/feature-flag-root-page/feature-flag-root-page-content/feature-flag-root-section-card/feature-flag-root-section-card-table/feature-flag-root-section-card-table.component.ts b/packages/frontend/projects/upgrade/src/app/features/dashboard/feature-flags/pages/feature-flag-root-page/feature-flag-root-page-content/feature-flag-root-section-card/feature-flag-root-section-card-table/feature-flag-root-section-card-table.component.ts index fae09a8b6..41ab0d312 100644 --- a/packages/frontend/projects/upgrade/src/app/features/dashboard/feature-flags/pages/feature-flag-root-page/feature-flag-root-page-content/feature-flag-root-section-card/feature-flag-root-section-card-table/feature-flag-root-section-card-table.component.ts +++ b/packages/frontend/projects/upgrade/src/app/features/dashboard/feature-flags/pages/feature-flag-root-page/feature-flag-root-page-content/feature-flag-root-section-card/feature-flag-root-section-card-table/feature-flag-root-section-card-table.component.ts @@ -113,7 +113,7 @@ export class FeatureFlagRootSectionCardTableComponent implements AfterViewInit, } get displayedColumns(): string[] { - return FLAG_ROOT_DISPLAYED_COLUMNS; + return this.canSelect ? ['select', ...FLAG_ROOT_DISPLAYED_COLUMNS] : FLAG_ROOT_DISPLAYED_COLUMNS; } get FLAG_TRANSLATION_KEYS() { diff --git a/packages/frontend/projects/upgrade/src/app/features/dashboard/feature-flags/pages/feature-flag-root-page/feature-flag-root-page-content/feature-flag-root-section-card/feature-flag-root-section-card.component.html b/packages/frontend/projects/upgrade/src/app/features/dashboard/feature-flags/pages/feature-flag-root-page/feature-flag-root-page-content/feature-flag-root-section-card/feature-flag-root-section-card.component.html index eceee0928..787bf0501 100644 --- a/packages/frontend/projects/upgrade/src/app/features/dashboard/feature-flags/pages/feature-flag-root-page/feature-flag-root-page-content/feature-flag-root-section-card/feature-flag-root-section-card.component.html +++ b/packages/frontend/projects/upgrade/src/app/features/dashboard/feature-flags/pages/feature-flag-root-page/feature-flag-root-page-content/feature-flag-root-section-card/feature-flag-root-section-card.component.html @@ -33,7 +33,7 @@ @if (isSectionCardExpanded) { - @if (!(isInitialLoading$ | async) && !batchView?.selectedCount) { + @if (!(isInitialLoading$ | async)) {
diff --git a/packages/frontend/projects/upgrade/src/app/features/dashboard/segments/pages/segment-root-page/segment-root-page-content/segment-root-section-card/segment-root-section-card-table/segment-root-section-card-table.component.html b/packages/frontend/projects/upgrade/src/app/features/dashboard/segments/pages/segment-root-page/segment-root-page-content/segment-root-section-card/segment-root-section-card-table/segment-root-section-card-table.component.html index 5f03b7fe1..04278300b 100644 --- a/packages/frontend/projects/upgrade/src/app/features/dashboard/segments/pages/segment-root-page/segment-root-page-content/segment-root-section-card/segment-root-section-card-table/segment-root-section-card-table.component.html +++ b/packages/frontend/projects/upgrade/src/app/features/dashboard/segments/pages/segment-root-page/segment-root-page-content/segment-root-section-card/segment-root-section-card-table/segment-root-section-card-table.component.html @@ -9,98 +9,86 @@ [dataSource]="segments" [trackBy]="trackBySegmentId" [class.has-batch-selection]="selection?.selectedCount" + [class.has-batch-controls]="canSelect" [ngClass]="{ 'no-data': !segments?.length }" matSort (matSortChange)="changeSorting($event)" [matSortActive]="segmentSortKey$ | async" [matSortDirection]="segmentSortAs$ | async | lowercase" > + + +
+ + + diff --git a/packages/frontend/projects/upgrade/src/app/features/dashboard/segments/pages/segment-root-page/segment-root-page-content/segment-root-section-card/segment-root-section-card-table/segment-root-section-card-table.component.scss b/packages/frontend/projects/upgrade/src/app/features/dashboard/segments/pages/segment-root-page/segment-root-page-content/segment-root-section-card/segment-root-section-card-table/segment-root-section-card-table.component.scss index 189490e6c..b818fef38 100644 --- a/packages/frontend/projects/upgrade/src/app/features/dashboard/segments/pages/segment-root-page/segment-root-page-content/segment-root-section-card/segment-root-section-card-table/segment-root-section-card-table.component.scss +++ b/packages/frontend/projects/upgrade/src/app/features/dashboard/segments/pages/segment-root-page/segment-root-page-content/segment-root-section-card/segment-root-section-card-table/segment-root-section-card-table.component.scss @@ -63,14 +63,22 @@ } } + .batch-select-column { + width: 56px; + min-width: 56px; + padding: 0 8px; + } + + &.has-batch-controls .name-column { + width: calc(22% - 56px); + padding-left: 0; + padding-right: 16px; + } + .name-column { width: 22%; padding-left: 32px; - &:has(.batch-checkbox) { - padding-left: 8px; - } - .segment-name { color: var(--black-2); } diff --git a/packages/frontend/projects/upgrade/src/app/features/dashboard/segments/pages/segment-root-page/segment-root-page-content/segment-root-section-card/segment-root-section-card-table/segment-root-section-card-table.component.ts b/packages/frontend/projects/upgrade/src/app/features/dashboard/segments/pages/segment-root-page/segment-root-page-content/segment-root-section-card/segment-root-section-card-table/segment-root-section-card-table.component.ts index 20ade4507..ed010fd71 100644 --- a/packages/frontend/projects/upgrade/src/app/features/dashboard/segments/pages/segment-root-page/segment-root-page-content/segment-root-section-card/segment-root-section-card-table/segment-root-section-card-table.component.ts +++ b/packages/frontend/projects/upgrade/src/app/features/dashboard/segments/pages/segment-root-page/segment-root-page-content/segment-root-section-card/segment-root-section-card-table/segment-root-section-card-table.component.ts @@ -105,7 +105,7 @@ export class SegmentRootSectionCardTableComponent implements AfterViewInit, OnDe } get displayedColumns(): string[] { - return SEGMENT_ROOT_DISPLAYED_COLUMNS; + return this.canSelect ? ['select', ...SEGMENT_ROOT_DISPLAYED_COLUMNS] : SEGMENT_ROOT_DISPLAYED_COLUMNS; } get SEGMENT_TRANSLATION_KEYS() { diff --git a/packages/frontend/projects/upgrade/src/app/features/dashboard/segments/pages/segment-root-page/segment-root-page-content/segment-root-section-card/segment-root-section-card.component.html b/packages/frontend/projects/upgrade/src/app/features/dashboard/segments/pages/segment-root-page/segment-root-page-content/segment-root-section-card/segment-root-section-card.component.html index 9fc27e17c..141a30d9e 100644 --- a/packages/frontend/projects/upgrade/src/app/features/dashboard/segments/pages/segment-root-page/segment-root-page-content/segment-root-section-card/segment-root-section-card.component.html +++ b/packages/frontend/projects/upgrade/src/app/features/dashboard/segments/pages/segment-root-page/segment-root-page-content/segment-root-section-card/segment-root-section-card.component.html @@ -33,7 +33,7 @@ @if (isSectionCardExpanded) { - @if (!(isInitialLoading$ | async) && !batchView?.selectedCount) { + @if (!(isInitialLoading$ | async)) {
diff --git a/packages/frontend/projects/upgrade/src/styles/shared-batch-selection.scss b/packages/frontend/projects/upgrade/src/styles/shared-batch-selection.scss index 2ccaddfde..fdeae114d 100644 --- a/packages/frontend/projects/upgrade/src/styles/shared-batch-selection.scss +++ b/packages/frontend/projects/upgrade/src/styles/shared-batch-selection.scss @@ -1,27 +1,14 @@ @use '@angular/material' as mat; -.batch-name-cell { - display: flex; - align-items: center; - gap: 8px; - min-width: 0; -} - -.batch-name-text { - flex: 1; - min-width: 0; -} - // Match the theme's checkbox specificity; this partial is loaded after the theme. -.batch-name-cell .mat-mdc-checkbox.batch-checkbox { - flex: 0 0 40px; +.batch-select-column .mat-mdc-checkbox.batch-checkbox { width: 40px; @include mat.checkbox-overrides( ( - unselected-icon-color: #adadad, - unselected-hover-icon-color: #adadad, - unselected-focus-icon-color: #adadad, + unselected-icon-color: var(--grey-7), + unselected-hover-icon-color: var(--grey-7), + unselected-focus-icon-color: var(--grey-7), selected-hover-state-layer-opacity: 0, unselected-hover-state-layer-opacity: 0, selected-focus-state-layer-opacity: 0, @@ -39,10 +26,6 @@ } } -.batch-name-sort { - white-space: nowrap; -} - // Keep the header aligned; hidden selections must still be available to clear. .no-data:not(.has-batch-selection) .mat-mdc-header-row .batch-checkbox { visibility: hidden; diff --git a/packages/frontend/projects/upgrade/src/styles/variables.scss b/packages/frontend/projects/upgrade/src/styles/variables.scss index 595041762..75431a19f 100644 --- a/packages/frontend/projects/upgrade/src/styles/variables.scss +++ b/packages/frontend/projects/upgrade/src/styles/variables.scss @@ -43,6 +43,7 @@ --grey-4: #959dac; --grey-5: #939bab; --grey-6: #6e6f72; + --grey-7: #adadad; --light-grey: #d3d3d3; --light-black: #222b45; --grey-message: #8f9bb3; From 4604b0b7185b0282aa7c884e20ed631aac03177d Mon Sep 17 00:00:00 2001 From: Zack Lee Date: Thu, 17 Sep 2026 13:03:49 -0400 Subject: [PATCH 46/61] refactor: remove unused batch pagination actions and redundant pruning --- .../batch-actions.integration.spec.ts | 10 +++++ .../core/batch-actions/batch-actions.store.ts | 40 ++++++++++--------- .../experiments/store/experiments.actions.ts | 5 --- .../store/experiments.effects.spec.ts | 10 +++++ .../store/experiments.reducer.spec.ts | 15 ------- .../experiments/store/experiments.reducer.ts | 1 - .../store/feature-flags.actions.ts | 2 - .../store/feature-flags.reducer.ts | 1 - 8 files changed, 41 insertions(+), 43 deletions(-) diff --git a/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.integration.spec.ts b/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.integration.spec.ts index 743c1a598..788a87f7a 100644 --- a/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.integration.spec.ts +++ b/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.integration.spec.ts @@ -287,6 +287,10 @@ describe.each(fixtures)('$entity batch store/effects integration', (config) => { store.dispatch(config.fetch({ fromStarting: false })); expect(data[config.fetchMethod]).toHaveBeenLastCalledWith(expect.objectContaining({ skip: 2 }), false); expect(batch().listLoading).toBe(true); + const fetchCount = data[config.fetchMethod].mock.calls.length; + store.dispatch(config.fetch({ fromStarting: false })); + expect(data[config.fetchMethod]).toHaveBeenCalledTimes(fetchCount); + expect(nextPage.observed).toBe(true); expect(selectionView(batch(), config.entity).canToggleHeader).toBe(true); store.dispatch(actions.toggleHeader({ items: currentRows().map(selectionItem) })); expect(Object.keys(batch().selectedById)).toEqual(rows.slice(0, 2).map(({ id }) => id)); @@ -299,9 +303,15 @@ describe.each(fixtures)('$entity batch store/effects integration', (config) => { checked: false, indeterminate: true, }); + const pendingReplacement = new Subject(); + data[config.fetchMethod].mockReturnValueOnce(pendingReplacement); + store.dispatch(config.fetch({ fromStarting: true })); + expect(batch().listLoading).toBe(true); data[config.fetchMethod].mockReturnValueOnce(of(page([rows[0], rows[0]]))); store.dispatch(config.fetch({ fromStarting: true })); + expect(pendingReplacement.observed).toBe(false); expect(currentRows()).toHaveLength(1); + expect(batch().listLoading).toBe(false); }); it('prepares immediately from retained metadata and invalidates the snapshot after deselection', () => { diff --git a/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.store.ts b/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.store.ts index 23c105e48..95a8fb3f7 100644 --- a/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.store.ts +++ b/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.store.ts @@ -42,8 +42,6 @@ export function withRootBatch( if (action.type === config.listSuccessType) { rootBatch = receiveListRows(rootBatch, action[config.responseRowsKey].map(selectionItem), !!action.fromStarting); } - // Tombstones also protect against late detail/stat responses, whose IDs are not tied to a root query. - const removed = new Set(rootBatch.removedIds); let result = next; // A cancelled tracked read cannot dispatch its usual success/failure action to clear loading. if (state.rootBatch.listLoading && state.rootBatch.listRequestId && !rootBatch.listRequestId) @@ -53,14 +51,27 @@ export function withRootBatch( const distinct = new Map(rows.map((row) => [row.id, row])); if (distinct.size !== rows.length) result = { ...result, [config.rowsKey]: [...distinct.values()] }; } - const prune = (key: string) => { - const rows = result[key]; - if (Array.isArray(rows) && rows.some((row) => removed.has(row.id))) - result = { ...result, [key]: rows.filter((row) => !removed.has(row.id)) }; - }; - prune(config.rowsKey); - prune('allExperimentNames'); - prune('listSegmentOptions'); + // Tombstones also protect against late detail/stat responses, whose IDs are not tied to a root query. + if (rootBatch.removedIds.length) { + const removed = new Set(rootBatch.removedIds); + const prune = (key: string) => { + const rows = result[key]; + if (Array.isArray(rows) && rows.some((row) => removed.has(row.id))) + result = { ...result, [key]: rows.filter((row) => !removed.has(row.id)) }; + }; + prune(config.rowsKey); + prune('allExperimentNames'); + prune('listSegmentOptions'); + if (removed.has(result['selectedFlag']?.id)) result = { ...result, selectedFlag: null }; + for (const key of ['stats', 'rewardsSummaries']) { + if (result[key] && Object.keys(result[key]).some((id) => removed.has(id))) { + result = { + ...result, + [key]: Object.fromEntries(Object.entries(result[key]).filter(([id]) => !removed.has(id))), + }; + } + } + } if ( (action.type === config.actions.listFailed.type || action.type === config.actions.batchDeleteRequested.type) && rootBatch !== state.rootBatch @@ -68,15 +79,6 @@ export function withRootBatch( // Failed reads and reads cancelled by submission leave the displayed rows available for selection. rootBatch = { ...rootBatch, loadedIds: result[config.rowsKey].map((row) => row.id) }; } - if (removed.has(result['selectedFlag']?.id)) result = { ...result, selectedFlag: null }; - for (const key of ['stats', 'rewardsSummaries']) { - if (result[key] && Object.keys(result[key]).some((id) => removed.has(id))) { - result = { - ...result, - [key]: Object.fromEntries(Object.entries(result[key]).filter(([id]) => !removed.has(id))), - }; - } - } if ( rootBatch.removedIds.length > state.rootBatch.removedIds.length || (isBatchBusy(state.rootBatch) && diff --git a/packages/frontend/projects/upgrade/src/app/core/experiments/store/experiments.actions.ts b/packages/frontend/projects/upgrade/src/app/core/experiments/store/experiments.actions.ts index 2cee9ec68..b6ce6180a 100644 --- a/packages/frontend/projects/upgrade/src/app/core/experiments/store/experiments.actions.ts +++ b/packages/frontend/projects/upgrade/src/app/core/experiments/store/experiments.actions.ts @@ -158,11 +158,6 @@ export const actionUpdateExperimentConditionsFailure = createAction( '[Experiment] Update Experiment Conditions Failure' ); -export const actionSetSkipExperiment = createAction( - '[Experiment] Set Skip Experiment Value', - props<{ skipExperiment: number }>() -); - export const actionSetSearchKey = createAction( '[Experiment] Set Search key value', props<{ searchKey: EXPERIMENT_SEARCH_KEY }>() 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 38f4720cf..e442e1416 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 @@ -144,6 +144,16 @@ describe('ExperimentEffects', () => { it.each([false, true])( 'correlates root rows with the request and fetches stats (replacement=%s)', (fromStarting) => { + if (fromStarting) { + store$.next({ + experiments: { + ...initialExperimentState, + skipExperiment: 2, + totalExperiments: 1, + searchString: 'test', + }, + }); + } const experiments = [{ id: 'test1' } as any]; experimentDataService.getAllExperiment = jest.fn().mockReturnValue(of({ nodes: experiments, total: 1 })); const results = []; diff --git a/packages/frontend/projects/upgrade/src/app/core/experiments/store/experiments.reducer.spec.ts b/packages/frontend/projects/upgrade/src/app/core/experiments/store/experiments.reducer.spec.ts index ce827fa57..ac603fa84 100644 --- a/packages/frontend/projects/upgrade/src/app/core/experiments/store/experiments.reducer.spec.ts +++ b/packages/frontend/projects/upgrade/src/app/core/experiments/store/experiments.reducer.spec.ts @@ -26,7 +26,6 @@ import { actionSetSearchKey, actionSetSearchParams, actionSetSearchString, - actionSetSkipExperiment, actionSetSortingType, actionSetSortKey, actionUpdateExperimentState, @@ -657,20 +656,6 @@ describe('ExperimentsReducer', () => { expect(newState.sortAs).toEqual(SORT_AS_DIRECTION.ASCENDING); }); - it('action "actionSetSkipExperiment" should set experiment skip value', () => { - const previousState = { ...initialState }; - previousState.skipExperiment = 2; - - const testAction: Action = actionSetSkipExperiment({ - skipExperiment: 3, - }); - - const newState = experimentsReducer(previousState, testAction); - - expect(newState).not.toBe(previousState); - expect(newState.skipExperiment).toEqual(3); - }); - it('action "actionFetchAllExperimentNamesSuccess" should set all experimet names', () => { const previousState = { ...initialState }; previousState.allExperimentNames = []; diff --git a/packages/frontend/projects/upgrade/src/app/core/experiments/store/experiments.reducer.ts b/packages/frontend/projects/upgrade/src/app/core/experiments/store/experiments.reducer.ts index bf2147a05..9bd4c40c6 100644 --- a/packages/frontend/projects/upgrade/src/app/core/experiments/store/experiments.reducer.ts +++ b/packages/frontend/projects/upgrade/src/app/core/experiments/store/experiments.reducer.ts @@ -235,7 +235,6 @@ const reducer = createReducer( })), on(experimentsAction.actionSetSortKey, (state, { sortKey }) => ({ ...state, sortKey })), on(experimentsAction.actionSetSortingType, (state, { sortingType }) => ({ ...state, sortAs: sortingType })), - on(experimentsAction.actionSetSkipExperiment, (state, { skipExperiment }) => ({ ...state, skipExperiment })), on(experimentsAction.actionFetchAllExperimentNamesSuccess, (state, { allExperimentNames }) => ({ ...state, allExperimentNames, diff --git a/packages/frontend/projects/upgrade/src/app/core/feature-flags/store/feature-flags.actions.ts b/packages/frontend/projects/upgrade/src/app/core/feature-flags/store/feature-flags.actions.ts index a2895dabc..f88d1618f 100644 --- a/packages/frontend/projects/upgrade/src/app/core/feature-flags/store/feature-flags.actions.ts +++ b/packages/frontend/projects/upgrade/src/app/core/feature-flags/store/feature-flags.actions.ts @@ -132,8 +132,6 @@ export const actionSetIsLoadingFeatureFlags = createAction( props<{ isLoadingFeatureFlags: boolean }>() ); -export const actionSetSkipFlags = createAction('[Feature Flags] Set Skip Flags', props<{ skipFlags: number }>()); - export const actionSetSearchKey = createAction( '[Feature Flags] Set Search key value', props<{ searchKey: FLAG_SEARCH_KEY }>() diff --git a/packages/frontend/projects/upgrade/src/app/core/feature-flags/store/feature-flags.reducer.ts b/packages/frontend/projects/upgrade/src/app/core/feature-flags/store/feature-flags.reducer.ts index 1d019378c..65807fc9d 100644 --- a/packages/frontend/projects/upgrade/src/app/core/feature-flags/store/feature-flags.reducer.ts +++ b/packages/frontend/projects/upgrade/src/app/core/feature-flags/store/feature-flags.reducer.ts @@ -156,7 +156,6 @@ const reducer = createReducer( ...state, isLoadingImportFeatureFlag, })), - on(FeatureFlagsActions.actionSetSkipFlags, (state, { skipFlags }) => ({ ...state, skipFlags })), on(FeatureFlagsActions.actionSetSearchKey, (state, { searchKey }) => ({ ...state, searchKey })), on(FeatureFlagsActions.actionSetSearchString, (state, { searchString }) => ({ ...state, searchValue: searchString })), on(FeatureFlagsActions.actionSetSortKey, (state, { sortKey }) => ({ ...state, sortKey })), From a2ece1e9b089e0129c93b4ac4fa505ecef1af89c Mon Sep 17 00:00:00 2001 From: Zack Lee Date: Thu, 17 Sep 2026 13:30:49 -0400 Subject: [PATCH 47/61] refactor: simplify segment batch recomputation options --- packages/backend/CLAUDE.md | 14 +++++++------- .../backend/src/api/services/BatchDeleteService.ts | 2 +- .../backend/src/api/services/SegmentService.ts | 5 ++--- 3 files changed, 10 insertions(+), 11 deletions(-) diff --git a/packages/backend/CLAUDE.md b/packages/backend/CLAUDE.md index 3621c8c25..8a938d386 100644 --- a/packages/backend/CLAUDE.md +++ b/packages/backend/CLAUDE.md @@ -83,10 +83,10 @@ Segment inclusion/exclusion for **feature flags** is precomputed and stored flat - **`FeatureFlagPrecomputedSegment` entity** (`src/api/models/FeatureFlagPrecomputedSegment.ts`) — one row per feature flag, columns: `featureFlagId` (PK), `inclusionIds: text[]`, `exclusionIds: text[]`. FK to `feature_flag` with `onDelete: CASCADE`. - **`FeatureFlagPrecomputedSegmentService`** (`src/api/services/FeatureFlagPrecomputedSegmentService.ts`) — owns all computation and cache logic: - - `recomputeForFlag(flagId)` — flattens all enabled inclusion/exclusion segments (recursive sub-segments) into flat ID arrays and upserts the row. This is the only method that `await`s — callers on write paths never call it directly. + - `recomputeForFlag(flagId)` — flattens all enabled inclusion/exclusion segments (recursive sub-segments) into flat ID arrays and upserts the row. Imports and batch segment deletion await this method directly. - `scheduleRecomputeForFlags(flagIds[])` — fire-and-forget recompute for a known set of flags (swallows/logs errors). The flag-side counterpart to `scheduleRecomputeForSegment`. - `scheduleRecomputeForSegment(segmentId)` — fire-and-forget; finds all flags referencing a segment (and its parents) and recomputes each. - - `withRecompute(logger, resolveAffectedFlagIds, work)` — **the wrapper all top-level write methods use.** Resolves affected flag IDs *before* `work`, runs `work` (which must own/commit its own transaction), then fires a fire-and-forget recompute *after* commit. Keeps mutation + recompute in one call so a refactor can't drop the recompute. `work` is never blocked on the recompute. + - `withRecompute(logger, resolveAffectedFlagIds, work)` — **the wrapper for background recomputation on write paths.** Resolves affected flag IDs *before* `work`, runs `work` (which must own/commit its own transaction), then fires a fire-and-forget recompute *after* commit. Keeps mutation + recompute in one call so a refactor can't drop the recompute. `work` is never blocked on the recompute. - `getAffectedFlagIds(segmentId)` — public helper that returns flag IDs affected by a given segment (used as the `resolveAffectedFlagIds` for segment deletes). - `getPrecomputedSets(flagIds[])` — cache-wrapped batch fetch, returns a `Map`. - `backfillMissingFlags(logger)` — called at startup; computes rows only for flags that have none yet (no-op once all flags are populated). @@ -105,14 +105,14 @@ Segment inclusion/exclusion for **feature flags** is precomputed and stored flat | Private list added to a shared segment | `SegmentService.addList` → `scheduleRecomputeForSegment` | | Private list removed from a shared segment | `SegmentService.deleteList` → `scheduleRecomputeForSegment` | | Segment members/structure updated | `SegmentService.addSegmentDataWithPipeline` → `scheduleRecomputeForSegment` | -| Segment deleted entirely | `SegmentService.deleteSegment` → `withRecompute` (collects affected flag IDs **before** the delete, recomputes **after** commit) | +| Segment deleted entirely | `SegmentService.deleteSegment` → `withRecompute` for single deletion; batch deletion collects affected flag IDs after the target lock and **before** deletion, then awaits recomputation **after** commit | | Server startup | `app.ts` → `backfillMissingFlags` — backfills any flag with no row | -All recomputes triggered from write paths are **fire-and-forget** — no request handler (flag-side or segment-side) ever blocks on a recompute. The `import*` paths are the one exception: they `await recomputeForFlag` so "import complete" means the rows are ready. +Ordinary write paths use **fire-and-forget** recomputation. The `import*` paths await `recomputeForFlag` so "import complete" means the rows are ready. Batch segment deletion also awaits post-commit recomputation for both flags and experiments before processing the next segment. `SegmentService.deleteSegment` selects this awaited branch when supplied with the batch transaction executor; ordinary callers retain background recomputation. ### Key invariant -The `feature_flag_precomputed_segment` row must always be recomputed **after** the structural change commits, so the flat arrays reflect the new state. For deletions specifically, affected flag IDs must be collected **before** the delete because the join table records are gone afterward. Both halves of this invariant are enforced by `withRecompute` (resolve-before → work → recompute-after), so top-level write methods get the ordering for free rather than hand-rolling it. +The `feature_flag_precomputed_segment` row must always be recomputed **after** the structural change commits, so the flat arrays reflect the new state. For deletions specifically, affected flag IDs must be collected **before** the delete because the join table records are gone afterward. `withRecompute` enforces this ordering for background recomputation. The batch branch collects owners inside the supplied transaction, after the target lock is held, and waits for all post-commit updates to settle before propagating any failure. ## Precomputed Segment Lists (Experiments) @@ -144,10 +144,10 @@ Experiment join tables (`ExperimentSegmentInclusion` / `ExperimentSegmentExclusi | Experiment lists imported | `ExperimentService.importExperimentLists` → `await recomputeForExperiment` after the import transaction commits | | Experiment context changed (deletes all its lists) | `ExperimentService.updateExperimentInDB` → `scheduleRecomputeForExperiments` after commit (recomputes to empty; `deleteAllListsFromExperiment` deletes the private segments directly, so the precomputed row would otherwise keep stale IDs). When a caller owns the transaction (`MoocletExperimentService.syncUpdate` / `syncUpdateWithMoocletAlgorithmTransition`), those methods recompute after their own commit — mirrors the flag side's `updateFeatureFlagInDB` → `withRecompute` | | Shared segment members/structure changed | `SegmentService.addList` / `deleteList` / `addSegmentDataWithPipeline` → `scheduleRecomputeForSegment` for **both** the flag and experiment services | -| Segment deleted entirely | `SegmentService.deleteSegment` → collects affected experiment IDs **before** the delete, recomputes **after** commit (flags use `withRecompute` in the same method) | +| Segment deleted entirely | `SegmentService.deleteSegment` → collects affected experiment IDs **before** deletion and recomputes **after** commit; single deletion schedules background work, while batch deletion collects owners after the target lock and awaits recomputation | | Server startup | `app.ts` → `backfillExperimentPrecomputedSegments` (guarded by `.catch` — a missing table never crashes startup) | -Same invariant as feature flags: recompute **after** the change commits; for deletes, collect affected experiment IDs **before**. All write-path recomputes are fire-and-forget except the `create` / import / Mooclet paths, which `await` so "done" means the row is ready. +Same invariant as feature flags: recompute **after** the change commits; for deletes, collect affected experiment IDs **before**. Write-path recomputes are fire-and-forget except the `create` / import / Mooclet paths and batch segment deletion, which await recomputation. A batch finishes each segment's post-commit updates before deleting the next segment. ### INCLUDE_ALL semantics (resolved) diff --git a/packages/backend/src/api/services/BatchDeleteService.ts b/packages/backend/src/api/services/BatchDeleteService.ts index 89c2ce438..abae6f5e2 100644 --- a/packages/backend/src/api/services/BatchDeleteService.ts +++ b/packages/backend/src/api/services/BatchDeleteService.ts @@ -166,7 +166,7 @@ export class BatchDeleteService { } else if (entity === 'flags') { await this.flags.delete(id, user, logger, executeTransaction); } else { - await this.segments.deleteSegment(id, logger, executeTransaction, true); + await this.segments.deleteSegment(id, logger, executeTransaction); } return committed ? { id, outcome: 'deleted' } diff --git a/packages/backend/src/api/services/SegmentService.ts b/packages/backend/src/api/services/SegmentService.ts index baa7e0ef1..3f8bb40fc 100644 --- a/packages/backend/src/api/services/SegmentService.ts +++ b/packages/backend/src/api/services/SegmentService.ts @@ -539,14 +539,13 @@ export class SegmentService { public async deleteSegment( id: string, logger: UpgradeLogger, - executeTransaction?: DeletionTransaction, - waitForRecompute = false + executeTransaction?: DeletionTransaction ): Promise { logger.info({ message: `Delete segment by id. segmentId: ${id}` }); const transaction: DeletionTransaction = executeTransaction || ((work) => this.dataSource.transaction(work)); - if (waitForRecompute) { + if (executeTransaction) { let affectedFlagIds: string[]; let affectedExperimentIds: string[]; const deletedSegment = await transaction(async (transactionalEntityManager) => { From c5d639e7e89b8b2a9ee4ea684a43642b648c5333 Mon Sep 17 00:00:00 2001 From: Zack Lee Date: Thu, 17 Sep 2026 14:14:07 -0400 Subject: [PATCH 48/61] fix: keep root table selection checkboxes visible --- .../upgrade/src/styles/shared-batch-selection.scss | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/packages/frontend/projects/upgrade/src/styles/shared-batch-selection.scss b/packages/frontend/projects/upgrade/src/styles/shared-batch-selection.scss index fdeae114d..75dbd111f 100644 --- a/packages/frontend/projects/upgrade/src/styles/shared-batch-selection.scss +++ b/packages/frontend/projects/upgrade/src/styles/shared-batch-selection.scss @@ -30,15 +30,3 @@ .no-data:not(.has-batch-selection) .mat-mdc-header-row .batch-checkbox { visibility: hidden; } - -@media (hover: hover) and (pointer: fine) { - .batch-checkbox { - opacity: 0; - } - .mat-mdc-row:hover .batch-checkbox, - .mat-mdc-header-row:hover .batch-checkbox, - .batch-checkbox:has(input:focus-visible), - .has-batch-selection .batch-checkbox { - opacity: 1; - } -} From eea5736b5f15a8c106c00211dc6331e47c657420 Mon Sep 17 00:00:00 2001 From: Zack Lee Date: Thu, 17 Sep 2026 14:20:35 -0400 Subject: [PATCH 49/61] fix: finish post-commit deletion work before reporting release failures --- .../src/api/services/BatchDeleteService.ts | 6 +++-- .../unit/services/BatchDeleteService.test.ts | 26 ++++++++++++++++--- 2 files changed, 26 insertions(+), 6 deletions(-) diff --git a/packages/backend/src/api/services/BatchDeleteService.ts b/packages/backend/src/api/services/BatchDeleteService.ts index abae6f5e2..a47f64a2c 100644 --- a/packages/backend/src/api/services/BatchDeleteService.ts +++ b/packages/backend/src/api/services/BatchDeleteService.ts @@ -101,9 +101,9 @@ export class BatchDeleteService { let rolledBack = false; let commitAttempted = false; let mutationStarted = false; + let transactionError: unknown; const executeTransaction: DeletionTransaction = async (work: (manager: EntityManager) => Promise) => { const runner = this.dataSource.createQueryRunner(); - let transactionError: unknown; let response: T; try { await runner.connect(); @@ -149,7 +149,8 @@ export class BatchDeleteService { if (!transactionError || transactionError instanceof BatchDeleteSkippedError) transactionError = releaseError; } } - if (transactionError) throw transactionError; + // A committed deletion must finish its post-commit work before surfacing a release failure. + if (transactionError && !committed) throw transactionError; return response; }; try { @@ -168,6 +169,7 @@ export class BatchDeleteService { } else { await this.segments.deleteSegment(id, logger, executeTransaction); } + if (transactionError) throw transactionError; return committed ? { id, outcome: 'deleted' } : { id, outcome: 'unknown', reasonCode: DeletionReasonCode.OUTCOME_UNKNOWN }; diff --git a/packages/backend/test/unit/services/BatchDeleteService.test.ts b/packages/backend/test/unit/services/BatchDeleteService.test.ts index 4d89caa60..d22e655f5 100644 --- a/packages/backend/test/unit/services/BatchDeleteService.test.ts +++ b/packages/backend/test/unit/services/BatchDeleteService.test.ts @@ -1,7 +1,7 @@ import { randomUUID } from 'crypto'; import { performance } from 'perf_hooks'; import { DataSource, EntityManager, QueryRunner } from 'typeorm'; -import { BatchDeleteEntity, DeletionReasonCode, UserRole } from 'upgrade_types'; +import { BatchDeleteEntity, CACHE_PREFIX, DeletionReasonCode, UserRole } from 'upgrade_types'; import { BatchDeleteService } from '../../../src/api/services/BatchDeleteService'; import { ExperimentService } from '../../../src/api/services/ExperimentService'; import { FeatureFlagService } from '../../../src/api/services/FeatureFlagService'; @@ -121,8 +121,13 @@ describe('BatchDeleteService transaction outcomes', () => { } }); - test('finishes both owner recomputations before deleting the next segment', async () => { + test.each([false, true])('awaits segment post-commit work (release fails: %s)', async (releaseFails) => { const selectedIds = ids.slice(0, 2); + if (releaseFails) { + configureRunner = (runner) => { + (runner.release as jest.Mock).mockRejectedValue(new Error('release failed')); + }; + } const saved = { flags: [] as string[], experiments: [] as string[] }; let releaseFirstWrites: () => void; const firstWrites = new Promise((resolve) => (releaseFirstWrites = resolve)); @@ -161,8 +166,21 @@ describe('BatchDeleteService transaction outcomes', () => { releaseFirstWrites(); await deletion; } - expect(saved).toEqual({ flags: [], experiments: [] }); - expect((await deletion).results.map((item) => item.outcome)).toEqual(['deleted', 'deleted']); + const remainingIds = releaseFails ? selectedIds.slice(1) : []; + expect(saved).toEqual({ flags: remainingIds, experiments: remainingIds }); + expect(segmentService.cacheService.resetPrefixCache).toHaveBeenCalledWith(CACHE_PREFIX.SEGMENT_KEY_PREFIX); + expect(segmentService.cacheService.resetPrefixCache).toHaveBeenCalledWith( + CACHE_PREFIX.GLOBAL_EXCLUDE_SEGMENT_KEY_PREFIX + ); + expect((await deletion).results).toEqual( + releaseFails + ? [ + { id: selectedIds[0], outcome: 'deleted', reasonCode: DeletionReasonCode.POST_DELETE_FAILED }, + { id: selectedIds[1], outcome: 'not_attempted' }, + ] + : selectedIds.map((id) => ({ id, outcome: 'deleted' })) + ); + expect(createQueryRunner).toHaveBeenCalledTimes(releaseFails ? 1 : 2); }); test('stops without mutation when the target lookup fails', async () => { From 1f2bc88e531318a824653fecf5942790921bcdcc Mon Sep 17 00:00:00 2001 From: Zack Lee Date: Thu, 17 Sep 2026 15:17:43 -0400 Subject: [PATCH 50/61] fix: preserve errors from experiment list requests --- .../src/app/core/batch-actions/batch-actions.effects.ts | 4 ++-- .../app/core/experiments/store/experiments.effects.spec.ts | 5 +++-- .../src/app/core/experiments/store/experiments.effects.ts | 2 +- 3 files changed, 6 insertions(+), 5 deletions(-) diff --git a/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.effects.ts b/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.effects.ts index 6c3b1e0f9..bb86f5d69 100644 --- a/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.effects.ts +++ b/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.effects.ts @@ -103,14 +103,14 @@ export function trackedListRequest( dispatch: (action: Action) => void, request: () => Observable, success: (data: T, requestId: string) => Action[], - failure: () => Action[] + failure: (error: unknown) => Action[] ) { return defer(() => { const requestId = newBatchRequestId(); dispatch(actions.listRequested({ requestId })); return request().pipe( switchMap((data) => success(data, requestId)), - catchError(() => concat(of(actions.listFailed({ requestId })), of(...failure()))), + catchError((error) => concat(of(actions.listFailed({ requestId })), of(...failure(error)))), // NgRx queues a nested dispatch until the current action finishes. Observe our start before // treating a different token (including null on deletion/logout) as cancellation. takeUntil( 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 e442e1416..3fc1c6da7 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 @@ -130,13 +130,14 @@ describe('ExperimentEffects', () => { it.each([1, null])('reports a tracked list failure when total is %s', (totalExperiments) => { store$.next({ experiments: { ...initialExperimentState, totalExperiments } }); - experimentDataService.getAllExperiment = jest.fn().mockReturnValue(throwError(() => 'testError')); + const error = new Error('testError'); + experimentDataService.getAllExperiment = jest.fn().mockReturnValue(throwError(() => error)); const results = []; const subscription = service.getPaginatedExperiment$.subscribe((result) => results.push(result)); actions$.next(actionGetExperiments({})); expect(results).toEqual([ batchActions.listFailed({ requestId: expect.any(String) }), - actionGetExperimentsFailure({ error: null }), + actionGetExperimentsFailure({ error }), ]); subscription.unsubscribe(); }); 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 b951c7dbf..1938321ba 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 @@ -115,7 +115,7 @@ export class ExperimentEffects { }), experimentAction.actionFetchExperimentStats({ experimentIds: data.nodes.map((row) => row.id) }), ], - () => [experimentAction.actionGetExperimentsFailure({ error: null })] + (error) => [experimentAction.actionGetExperimentsFailure({ error })] ); }) ) From 131b90b70af339cf529eb255249b16e96049f632 Mon Sep 17 00:00:00 2001 From: Zack Lee Date: Thu, 17 Sep 2026 16:07:23 -0400 Subject: [PATCH 51/61] fix: reuse transaction connections throughout batch deletion --- .../api/repositories/ExperimentRepository.ts | 34 ++++++---- .../api/repositories/FeatureFlagRepository.ts | 60 +++++++++++++++++ .../src/api/repositories/SegmentRepository.ts | 4 +- .../ExperimentPrecomputedSegmentService.ts | 18 +++-- .../src/api/services/ExperimentService.ts | 2 +- .../FeatureFlagPrecomputedSegmentService.ts | 18 +++-- .../src/api/services/FeatureFlagService.ts | 64 ++---------------- .../services/PrecomputedSegmentServiceBase.ts | 19 ++++-- .../src/api/services/SegmentService.ts | 10 ++- .../test/integration/BatchDelete/index.ts | 65 +++++++++++++++++++ ...xperimentPrecomputedSegmentService.test.ts | 2 +- ...atureFlagPrecomputedSegmentService.test.ts | 2 +- .../unit/services/FeatureFlagService.test.ts | 1 + 13 files changed, 204 insertions(+), 95 deletions(-) diff --git a/packages/backend/src/api/repositories/ExperimentRepository.ts b/packages/backend/src/api/repositories/ExperimentRepository.ts index f1cfd1dcc..4f32b1c3f 100644 --- a/packages/backend/src/api/repositories/ExperimentRepository.ts +++ b/packages/backend/src/api/repositories/ExperimentRepository.ts @@ -460,16 +460,18 @@ export class ExperimentRepository extends Repository { } } - private buildConditionLevelPayloadQuery() { - return this.createQueryBuilder('experiment') + private buildConditionLevelPayloadQuery(repository: Repository = this) { + return repository + .createQueryBuilder('experiment') .leftJoinAndSelect('experiment.conditions', 'conditions') .leftJoinAndSelect('conditions.levelCombinationElements', 'levelCombinationElements') .leftJoinAndSelect('levelCombinationElements.level', 'level') .leftJoinAndSelect('conditions.conditionPayloads', 'conditionPayload'); } - private buildFactorDecisionPointPayloadQuery() { - return this.createQueryBuilder('experiment') + private buildFactorDecisionPointPayloadQuery(repository: Repository = this) { + return repository + .createQueryBuilder('experiment') .leftJoinAndSelect('experiment.partitions', 'partitions') .leftJoinAndSelect('experiment.stratificationFactor', 'stratificationFactor') .leftJoinAndSelect('partitions.conditionPayloads', 'conditionPayloads') @@ -478,8 +480,9 @@ export class ExperimentRepository extends Repository { .leftJoinAndSelect('factors.levels', 'levels'); } - private buildInclusionSegmentQuery() { - return this.createQueryBuilder('experiment') + private buildInclusionSegmentQuery(repository: Repository = this) { + return repository + .createQueryBuilder('experiment') .select('experiment.id') .leftJoinAndSelect('experiment.experimentSegmentInclusion', 'experimentSegmentInclusion') .leftJoinAndSelect('experimentSegmentInclusion.segment', 'segmentInclusion') @@ -488,8 +491,9 @@ export class ExperimentRepository extends Repository { .leftJoinAndSelect('segmentInclusion.subSegments', 'subSegment'); } - private buildExclusionSegmentQuery() { - return this.createQueryBuilder('experiment') + private buildExclusionSegmentQuery(repository: Repository = this) { + return repository + .createQueryBuilder('experiment') .select('experiment.id') .leftJoinAndSelect('experiment.experimentSegmentExclusion', 'experimentSegmentExclusion') .leftJoinAndSelect('experimentSegmentExclusion.segment', 'segmentExclusion') @@ -589,18 +593,20 @@ export class ExperimentRepository extends Repository { })); } - public async findOneExperiment(id: string): Promise { - const conditionLevelPayloadQuery = this.buildConditionLevelPayloadQuery() + public async findOneExperiment(id: string, entityManager?: EntityManager): Promise { + const repository = entityManager ? entityManager.getRepository(Experiment) : this; + const conditionLevelPayloadQuery = this.buildConditionLevelPayloadQuery(repository) .addOrderBy('conditions.order', 'ASC') .where({ id }); - const factorDecisionPointPayloadQuery = this.buildFactorDecisionPointPayloadQuery() + const factorDecisionPointPayloadQuery = this.buildFactorDecisionPointPayloadQuery(repository) .addOrderBy('partitions.order', 'ASC') .addOrderBy('factors.order', 'ASC') .addOrderBy('levels.order', 'ASC') .where({ id }); - const metricQuery = this.createQueryBuilder('experiment') + const metricQuery = repository + .createQueryBuilder('experiment') .leftJoinAndSelect('experiment.queries', 'queries') .leftJoinAndSelect('queries.metric', 'metric') .leftJoinAndSelect('experiment.stateTimeLogs', 'stateTimeLogs') @@ -608,8 +614,8 @@ export class ExperimentRepository extends Repository { .addOrderBy('queries.createdAt', 'ASC') .where({ id }); - const inclusionSegmentQuery = this.buildInclusionSegmentQuery().where({ id }); - const exclusionSegmentQuery = this.buildExclusionSegmentQuery().where({ id }); + const inclusionSegmentQuery = this.buildInclusionSegmentQuery(repository).where({ id }); + const exclusionSegmentQuery = this.buildExclusionSegmentQuery(repository).where({ id }); const [conditionLevelPayloadData, factorDecisionPointPayloadData, metricData, inclusionData, exclusionData] = await Promise.all([ diff --git a/packages/backend/src/api/repositories/FeatureFlagRepository.ts b/packages/backend/src/api/repositories/FeatureFlagRepository.ts index c6a1ea1f2..0defde4d7 100644 --- a/packages/backend/src/api/repositories/FeatureFlagRepository.ts +++ b/packages/backend/src/api/repositories/FeatureFlagRepository.ts @@ -4,9 +4,69 @@ import { FeatureFlag } from '../models/FeatureFlag'; import repositoryError from './utils/repositoryError'; import { FEATURE_FLAG_STATUS, FILTER_MODE } from 'upgrade_types'; import { FeatureFlagValidation } from '../controllers/validators/FeatureFlagValidator'; +import { IndividualForSegment } from '../models/IndividualForSegment'; +import { GroupForSegment } from '../models/GroupForSegment'; @EntityRepository(FeatureFlag) export class FeatureFlagRepository extends Repository { + public async findOneForDetails(id: string, entityManager?: EntityManager): Promise { + const repository = entityManager ? entityManager.getRepository(FeatureFlag) : this; + const manager = entityManager || this.manager; + const featureFlag = await repository + .createQueryBuilder('feature_flag') + .leftJoinAndSelect('feature_flag.featureFlagSegmentInclusion', 'featureFlagSegmentInclusion') + .leftJoinAndSelect('featureFlagSegmentInclusion.segment', 'segmentInclusion') + .leftJoinAndSelect('segmentInclusion.subSegments', 'subSegment') + .leftJoinAndSelect('feature_flag.featureFlagSegmentExclusion', 'featureFlagSegmentExclusion') + .leftJoinAndSelect('featureFlagSegmentExclusion.segment', 'segmentExclusion') + .leftJoinAndSelect('segmentExclusion.subSegments', 'subSegmentExclusion') + .where({ id }) + .getOne(); + + if (!featureFlag) { + return undefined; + } + + // loadRelationCountAndMap was removed in TypeORM 1.0; fetch member counts with two batch queries. + const segments = [ + ...(featureFlag.featureFlagSegmentInclusion ?? []).map((r) => r.segment), + ...(featureFlag.featureFlagSegmentExclusion ?? []).map((r) => r.segment), + ].filter(Boolean); + + if (segments.length > 0) { + const segmentIds = segments.map((s) => s.id); + + const [individualCounts, groupCounts] = await Promise.all([ + manager + .createQueryBuilder() + .select('ifs.segmentId', 'segmentId') + .addSelect('COUNT(*)', 'count') + .from(IndividualForSegment, 'ifs') + .where('ifs.segmentId IN (:...segmentIds)', { segmentIds }) + .groupBy('ifs.segmentId') + .getRawMany<{ segmentId: string; count: string }>(), + manager + .createQueryBuilder() + .select('gfs.segmentId', 'segmentId') + .addSelect('COUNT(*)', 'count') + .from(GroupForSegment, 'gfs') + .where('gfs.segmentId IN (:...segmentIds)', { segmentIds }) + .groupBy('gfs.segmentId') + .getRawMany<{ segmentId: string; count: string }>(), + ]); + + const individualCountMap = new Map(individualCounts.map((r) => [r.segmentId, Number.parseInt(r.count, 10)])); + const groupCountMap = new Map(groupCounts.map((r) => [r.segmentId, Number.parseInt(r.count, 10)])); + + segments.forEach((segment) => { + segment.individualForSegmentCount = individualCountMap.get(segment.id) ?? 0; + segment.groupForSegmentCount = groupCountMap.get(segment.id) ?? 0; + }); + } + + return featureFlag; + } + public async insertFeatureFlag(flagDoc: FeatureFlag, entityManager: EntityManager): Promise { const result = await entityManager .createQueryBuilder() diff --git a/packages/backend/src/api/repositories/SegmentRepository.ts b/packages/backend/src/api/repositories/SegmentRepository.ts index 2759e17ef..8883f1a6e 100644 --- a/packages/backend/src/api/repositories/SegmentRepository.ts +++ b/packages/backend/src/api/repositories/SegmentRepository.ts @@ -139,8 +139,8 @@ export class SegmentRepository extends Repository { return result.raw; } - public async findParentSegmentIds(segmentId: string): Promise { - const rows = await this.manager.query( + public async findParentSegmentIds(segmentId: string, entityManager?: EntityManager): Promise { + const rows = await (entityManager || this.manager).query( `SELECT "parentSegmentId" FROM "segment_for_segment" WHERE "childSegmentId" = $1`, [segmentId] ); diff --git a/packages/backend/src/api/services/ExperimentPrecomputedSegmentService.ts b/packages/backend/src/api/services/ExperimentPrecomputedSegmentService.ts index 03b7ffb87..80c9111be 100644 --- a/packages/backend/src/api/services/ExperimentPrecomputedSegmentService.ts +++ b/packages/backend/src/api/services/ExperimentPrecomputedSegmentService.ts @@ -6,6 +6,8 @@ import { ExperimentSegmentExclusionRepository } from '../repositories/Experiment import { ExperimentRepository } from '../repositories/ExperimentRepository'; import { SegmentRepository } from '../repositories/SegmentRepository'; import { ExperimentPrecomputedSegment } from '../models/ExperimentPrecomputedSegment'; +import { ExperimentSegmentInclusion } from '../models/ExperimentSegmentInclusion'; +import { ExperimentSegmentExclusion } from '../models/ExperimentSegmentExclusion'; import { CacheService } from './CacheService'; import { CACHE_PREFIX } from 'upgrade_types'; import { UpgradeLogger } from '../../lib/logger/UpgradeLogger'; @@ -53,13 +55,19 @@ export class ExperimentPrecomputedSegmentService extends PrecomputedSegmentServi }; } - protected async findOwnerIdsBySegmentId(segmentId: string): Promise { + protected async findOwnerIdsBySegmentId(segmentId: string, entityManager?: EntityManager): Promise { + const inclusionRepository = entityManager + ? entityManager.getRepository(ExperimentSegmentInclusion) + : this.experimentSegmentInclusionRepository; + const exclusionRepository = entityManager + ? entityManager.getRepository(ExperimentSegmentExclusion) + : this.experimentSegmentExclusionRepository; const [inclusionRecords, exclusionRecords] = await Promise.all([ - this.experimentSegmentInclusionRepository.find({ + inclusionRepository.find({ where: { segment: { id: segmentId } }, relations: { experiment: true }, }), - this.experimentSegmentExclusionRepository.find({ + exclusionRepository.find({ where: { segment: { id: segmentId } }, relations: { experiment: true }, }), @@ -98,8 +106,8 @@ export class ExperimentPrecomputedSegmentService extends PrecomputedSegmentServi this.scheduleRecomputeForOwners(experimentIds, logger); } - public getAffectedExperimentIds(segmentId: string): Promise { - return this.getAffectedOwnerIds(segmentId); + public getAffectedExperimentIds(segmentId: string, entityManager?: EntityManager): Promise { + return this.getAffectedOwnerIds(segmentId, entityManager); } public recomputeAllExperiments(logger: UpgradeLogger): Promise { diff --git a/packages/backend/src/api/services/ExperimentService.ts b/packages/backend/src/api/services/ExperimentService.ts index d7b48a726..28db21a03 100644 --- a/packages/backend/src/api/services/ExperimentService.ts +++ b/packages/backend/src/api/services/ExperimentService.ts @@ -441,7 +441,7 @@ export class ExperimentService { const entityManager = existingEntityManager || this.dataSource.manager; const transaction: DeletionTransaction = executeTransaction || ((work) => entityManager.transaction(work)); return await transaction(async (transactionalEntityManager) => { - const experiment = await this.experimentRepository.findOneExperiment(experimentId); + const experiment = await this.experimentRepository.findOneExperiment(experimentId, transactionalEntityManager); if (experiment) { await this.clearExperimentCacheDetail(experiment.context[0]); diff --git a/packages/backend/src/api/services/FeatureFlagPrecomputedSegmentService.ts b/packages/backend/src/api/services/FeatureFlagPrecomputedSegmentService.ts index 0519419db..dcbc1ba2d 100644 --- a/packages/backend/src/api/services/FeatureFlagPrecomputedSegmentService.ts +++ b/packages/backend/src/api/services/FeatureFlagPrecomputedSegmentService.ts @@ -6,6 +6,8 @@ import { FeatureFlagSegmentExclusionRepository } from '../repositories/FeatureFl import { FeatureFlagRepository } from '../repositories/FeatureFlagRepository'; import { SegmentRepository } from '../repositories/SegmentRepository'; import { FeatureFlagPrecomputedSegment } from '../models/FeatureFlagPrecomputedSegment'; +import { FeatureFlagSegmentInclusion } from '../models/FeatureFlagSegmentInclusion'; +import { FeatureFlagSegmentExclusion } from '../models/FeatureFlagSegmentExclusion'; import { CacheService } from './CacheService'; import { CACHE_PREFIX } from 'upgrade_types'; import { UpgradeLogger } from '../../lib/logger/UpgradeLogger'; @@ -56,13 +58,19 @@ export class FeatureFlagPrecomputedSegmentService extends PrecomputedSegmentServ }; } - protected async findOwnerIdsBySegmentId(segmentId: string): Promise { + protected async findOwnerIdsBySegmentId(segmentId: string, entityManager?: EntityManager): Promise { + const inclusionRepository = entityManager + ? entityManager.getRepository(FeatureFlagSegmentInclusion) + : this.featureFlagSegmentInclusionRepository; + const exclusionRepository = entityManager + ? entityManager.getRepository(FeatureFlagSegmentExclusion) + : this.featureFlagSegmentExclusionRepository; const [inclusionRecords, exclusionRecords] = await Promise.all([ - this.featureFlagSegmentInclusionRepository.find({ + inclusionRepository.find({ where: { segment: { id: segmentId } }, relations: { featureFlag: true }, }), - this.featureFlagSegmentExclusionRepository.find({ + exclusionRepository.find({ where: { segment: { id: segmentId } }, relations: { featureFlag: true }, }), @@ -101,8 +109,8 @@ export class FeatureFlagPrecomputedSegmentService extends PrecomputedSegmentServ this.scheduleRecomputeForOwners(flagIds, logger); } - public getAffectedFlagIds(segmentId: string): Promise { - return this.getAffectedOwnerIds(segmentId); + public getAffectedFlagIds(segmentId: string, entityManager?: EntityManager): Promise { + return this.getAffectedOwnerIds(segmentId, entityManager); } public recomputeAllFlags(logger: UpgradeLogger): Promise { diff --git a/packages/backend/src/api/services/FeatureFlagService.ts b/packages/backend/src/api/services/FeatureFlagService.ts index 3c763c1d9..f2ba94bf5 100644 --- a/packages/backend/src/api/services/FeatureFlagService.ts +++ b/packages/backend/src/api/services/FeatureFlagService.ts @@ -1,8 +1,6 @@ import { Service } from 'typedi'; import { FeatureFlag } from '../models/FeatureFlag'; import { Segment } from '../models/Segment'; -import { IndividualForSegment } from '../models/IndividualForSegment'; -import { GroupForSegment } from '../models/GroupForSegment'; import { FeatureFlagSegmentInclusion } from '../models/FeatureFlagSegmentInclusion'; import { FeatureFlagSegmentExclusion } from '../models/FeatureFlagSegmentExclusion'; import { FeatureFlagPrecomputedSegment } from '../models/FeatureFlagPrecomputedSegment'; @@ -185,63 +183,15 @@ export class FeatureFlagService { // Counts-only variant of findOne for the details page: maps member counts instead of loading // the member lists. Callers that need the actual members (e.g. exports) must use findOne. - public async findOneForDetails(id: string, logger?: UpgradeLogger): Promise { + public async findOneForDetails( + id: string, + logger?: UpgradeLogger, + entityManager?: EntityManager + ): Promise { if (logger) { logger.info({ message: `Find feature flag (details view) by id => ${id}` }); } - const featureFlag = await this.featureFlagRepository - .createQueryBuilder('feature_flag') - .leftJoinAndSelect('feature_flag.featureFlagSegmentInclusion', 'featureFlagSegmentInclusion') - .leftJoinAndSelect('featureFlagSegmentInclusion.segment', 'segmentInclusion') - .leftJoinAndSelect('segmentInclusion.subSegments', 'subSegment') - .leftJoinAndSelect('feature_flag.featureFlagSegmentExclusion', 'featureFlagSegmentExclusion') - .leftJoinAndSelect('featureFlagSegmentExclusion.segment', 'segmentExclusion') - .leftJoinAndSelect('segmentExclusion.subSegments', 'subSegmentExclusion') - .where({ id }) - .getOne(); - - if (!featureFlag) { - return undefined; - } - - // loadRelationCountAndMap was removed in TypeORM 1.0; fetch member counts with two batch queries. - const segments = [ - ...(featureFlag.featureFlagSegmentInclusion ?? []).map((r) => r.segment), - ...(featureFlag.featureFlagSegmentExclusion ?? []).map((r) => r.segment), - ].filter(Boolean); - - if (segments.length > 0) { - const segmentIds = segments.map((s) => s.id); - - const [individualCounts, groupCounts] = await Promise.all([ - this.dataSource - .createQueryBuilder() - .select('ifs.segmentId', 'segmentId') - .addSelect('COUNT(*)', 'count') - .from(IndividualForSegment, 'ifs') - .where('ifs.segmentId IN (:...segmentIds)', { segmentIds }) - .groupBy('ifs.segmentId') - .getRawMany<{ segmentId: string; count: string }>(), - this.dataSource - .createQueryBuilder() - .select('gfs.segmentId', 'segmentId') - .addSelect('COUNT(*)', 'count') - .from(GroupForSegment, 'gfs') - .where('gfs.segmentId IN (:...segmentIds)', { segmentIds }) - .groupBy('gfs.segmentId') - .getRawMany<{ segmentId: string; count: string }>(), - ]); - - const individualCountMap = new Map(individualCounts.map((r) => [r.segmentId, Number.parseInt(r.count, 10)])); - const groupCountMap = new Map(groupCounts.map((r) => [r.segmentId, Number.parseInt(r.count, 10)])); - - segments.forEach((segment) => { - segment.individualForSegmentCount = individualCountMap.get(segment.id) ?? 0; - segment.groupForSegmentCount = groupCountMap.get(segment.id) ?? 0; - }); - } - - return featureFlag; + return this.featureFlagRepository.findOneForDetails(id, entityManager); } public async create( @@ -349,7 +299,7 @@ export class FeatureFlagService { logger.info({ message: `Delete Feature Flag => ${featureFlagId}` }); const transaction: DeletionTransaction = executeTransaction || ((work) => this.dataSource.transaction(work)); return await transaction(async (transactionalEntityManager) => { - const featureFlag = await this.findOneForDetails(featureFlagId, logger); + const featureFlag = await this.findOneForDetails(featureFlagId, logger, transactionalEntityManager); if (featureFlag) { await this.clearCachedFlagsForContext(featureFlag.context[0]); diff --git a/packages/backend/src/api/services/PrecomputedSegmentServiceBase.ts b/packages/backend/src/api/services/PrecomputedSegmentServiceBase.ts index d268b660a..5d3dac473 100644 --- a/packages/backend/src/api/services/PrecomputedSegmentServiceBase.ts +++ b/packages/backend/src/api/services/PrecomputedSegmentServiceBase.ts @@ -3,6 +3,7 @@ import { SegmentRepository } from '../repositories/SegmentRepository'; import { CACHE_PREFIX } from 'upgrade_types'; import { UpgradeLogger } from '../../lib/logger/UpgradeLogger'; import { flattenSegmentMembers } from './precomputedSegmentHelpers'; +import { EntityManager } from 'typeorm'; export interface PrecomputedSegmentRow { inclusionIds: string[]; @@ -43,7 +44,7 @@ export abstract class PrecomputedSegmentServiceBase; /** owner IDs that DIRECTLY reference a given segment via inclusion or exclusion */ - protected abstract findOwnerIdsBySegmentId(segmentId: string): Promise; + protected abstract findOwnerIdsBySegmentId(segmentId: string, entityManager?: EntityManager): Promise; /** persist the flat arrays for one owner (subclass repo upsert) */ protected abstract upsertOwner(ownerId: string, inclusionIds: string[], exclusionIds: string[]): Promise; @@ -170,20 +171,24 @@ export abstract class PrecomputedSegmentServiceBase { - return [...(await this.collectAffectedOwnerIds(segmentId, new Set()))]; + public async getAffectedOwnerIds(segmentId: string, entityManager?: EntityManager): Promise { + return [...(await this.collectAffectedOwnerIds(segmentId, new Set(), entityManager))]; } - protected async collectAffectedOwnerIds(segmentId: string, visited: Set): Promise> { + protected async collectAffectedOwnerIds( + segmentId: string, + visited: Set, + entityManager?: EntityManager + ): Promise> { if (visited.has(segmentId)) return new Set(); visited.add(segmentId); - const ownerIds = new Set(await this.findOwnerIdsBySegmentId(segmentId)); + const ownerIds = new Set(await this.findOwnerIdsBySegmentId(segmentId, entityManager)); - const parentIds = await this.segmentRepository.findParentSegmentIds(segmentId); + const parentIds = await this.segmentRepository.findParentSegmentIds(segmentId, entityManager); await Promise.all( parentIds.map(async (parentId) => { - const parentOwnerIds = await this.collectAffectedOwnerIds(parentId, visited); + const parentOwnerIds = await this.collectAffectedOwnerIds(parentId, visited, entityManager); parentOwnerIds.forEach((id) => ownerIds.add(id)); }) ); diff --git a/packages/backend/src/api/services/SegmentService.ts b/packages/backend/src/api/services/SegmentService.ts index 3f8bb40fc..715e15f24 100644 --- a/packages/backend/src/api/services/SegmentService.ts +++ b/packages/backend/src/api/services/SegmentService.ts @@ -551,8 +551,14 @@ export class SegmentService { const deletedSegment = await transaction(async (transactionalEntityManager) => { // The batch executor holds the target lock before invoking this callback. // Collect owners before deletion removes the joins, including edits committed while waiting for the lock. - affectedFlagIds = await this.featureFlagPrecomputedSegmentService.getAffectedFlagIds(id); - affectedExperimentIds = await this.experimentPrecomputedSegmentService.getAffectedExperimentIds(id); + affectedFlagIds = await this.featureFlagPrecomputedSegmentService.getAffectedFlagIds( + id, + transactionalEntityManager + ); + affectedExperimentIds = await this.experimentPrecomputedSegmentService.getAffectedExperimentIds( + id, + transactionalEntityManager + ); return this.deleteSegmentAndPrivateSubsegments(id, logger, transactionalEntityManager); }); // A batch must finish this item's post-commit writes before deleting another segment for the same owner. diff --git a/packages/backend/test/integration/BatchDelete/index.ts b/packages/backend/test/integration/BatchDelete/index.ts index bffb5b9ee..d6aabbe88 100644 --- a/packages/backend/test/integration/BatchDelete/index.ts +++ b/packages/backend/test/integration/BatchDelete/index.ts @@ -191,6 +191,71 @@ export function registerBatchDeleteTests(connections: () => [DataSource, DataSou expect(await db.getRepository(Segment).countBy({ id: child.id })).toBe(1); }); + test.each(entities)('%s deletes with one pooled connection', async (entity) => { + const [row] = await create(entity); + const list = await ownedList(entity, row.id); + let flagId: string; + let experimentId: string; + if (entity === 'segments') { + const [flag] = await create('flags'); + const [experiment] = await create('experiments'); + flagId = flag.id; + experimentId = experiment.id; + const flagList = await ownedList('flags', flagId, row.id); + await db.getRepository(FeatureFlagSegmentInclusion).update({ segmentId: flagList.id }, { enabled: true }); + await ownedList('experiments', experimentId, row.id); + await db.getRepository(IndividualForSegment).insert({ segmentId: row.id, userId: 'deleted-member' }); + const logger = new UpgradeLogger(); + await Container.get(FeatureFlagPrecomputedSegmentService).recomputeForFlag(flagId, logger); + await Container.get(ExperimentPrecomputedSegmentService).recomputeForExperiment(experimentId, logger); + expect( + await db.getRepository(FeatureFlagPrecomputedSegment).findOneBy({ featureFlagId: flagId }) + ).toMatchObject({ + inclusionIds: expect.arrayContaining(['deleted-member']), + }); + expect(await db.getRepository(ExperimentPrecomputedSegment).findOneBy({ experimentId })).toMatchObject({ + inclusionIds: expect.arrayContaining(['deleted-member']), + }); + } + const originalExtra = db.options.extra; + await db.destroy(); + db.setOptions({ extra: { ...originalExtra, max: 1, connectionTimeoutMillis: 500 } }); + try { + await db.initialize(); + if (entity === 'flags') { + const { body: details } = await request(app).get(`/api/flags/${row.id}`).expect(200); + expect(details.featureFlagSegmentInclusion[0].segment).toMatchObject({ + id: list.id, + individualForSegmentCount: 1, + groupForSegmentCount: 0, + }); + } + const { body } = await request(app) + .post(route(entity)) + .send({ ids: [row.id] }) + .expect(200); + expect(body.results).toEqual([{ id: row.id, outcome: 'deleted' }]); + expect(await db.getRepository(model[entity]).countBy({ id: row.id })).toBe(0); + expect(await db.getRepository(Segment).countBy({ id: list.id })).toBe(0); + if (entity === 'segments') { + expect( + await db.getRepository(FeatureFlagPrecomputedSegment).findOneBy({ featureFlagId: flagId }) + ).toMatchObject({ + inclusionIds: ['batch-member'], + exclusionIds: [], + }); + expect(await db.getRepository(ExperimentPrecomputedSegment).findOneBy({ experimentId })).toMatchObject({ + inclusionIds: ['batch-member'], + exclusionIds: [], + }); + } + } finally { + if (db.isInitialized) await db.destroy(); + db.setOptions({ extra: originalExtra }); + await db.initialize(); + } + }); + test.each(entities)('%s skips a missing ID and deletes the remaining selection', async (entity) => { const [row] = await create(entity); const missing = randomUUID(); diff --git a/packages/backend/test/unit/services/ExperimentPrecomputedSegmentService.test.ts b/packages/backend/test/unit/services/ExperimentPrecomputedSegmentService.test.ts index 223006af5..0c7779dd9 100644 --- a/packages/backend/test/unit/services/ExperimentPrecomputedSegmentService.test.ts +++ b/packages/backend/test/unit/services/ExperimentPrecomputedSegmentService.test.ts @@ -143,7 +143,7 @@ describe('ExperimentPrecomputedSegmentService', () => { const affected = await service.getAffectedExperimentIds('segChild'); expect(affected).toEqual(['expP']); - expect(segmentRepository.findParentSegmentIds).toHaveBeenCalledWith('segChild'); + expect(segmentRepository.findParentSegmentIds).toHaveBeenCalledWith('segChild', undefined); }); it('does not infinitely recurse on a segment cycle', async () => { diff --git a/packages/backend/test/unit/services/FeatureFlagPrecomputedSegmentService.test.ts b/packages/backend/test/unit/services/FeatureFlagPrecomputedSegmentService.test.ts index 928708313..3aa494bab 100644 --- a/packages/backend/test/unit/services/FeatureFlagPrecomputedSegmentService.test.ts +++ b/packages/backend/test/unit/services/FeatureFlagPrecomputedSegmentService.test.ts @@ -139,7 +139,7 @@ describe('FeatureFlagPrecomputedSegmentService', () => { const affected = await service.getAffectedFlagIds('segChild'); expect(affected).toEqual(['flagP']); - expect(segmentRepository.findParentSegmentIds).toHaveBeenCalledWith('segChild'); + expect(segmentRepository.findParentSegmentIds).toHaveBeenCalledWith('segChild', undefined); }); it('does not infinitely recurse on a segment cycle', async () => { diff --git a/packages/backend/test/unit/services/FeatureFlagService.test.ts b/packages/backend/test/unit/services/FeatureFlagService.test.ts index e5d9f8792..e1e601de5 100644 --- a/packages/backend/test/unit/services/FeatureFlagService.test.ts +++ b/packages/backend/test/unit/services/FeatureFlagService.test.ts @@ -234,6 +234,7 @@ describe('Feature Flag Service Testing', () => { getFlagsForKeys: jest.fn().mockResolvedValue(mockFlagArr), getFlagsFromContext: jest.fn().mockResolvedValue(mockFlagArr), findOne: jest.fn().mockResolvedValue(mockFlag1), + findOneForDetails: jest.fn().mockResolvedValue(mockFlag1), findWithNames: jest.fn().mockResolvedValue(mockFlagArr), findOneById: jest.fn().mockResolvedValue(mockFlag1), count: jest.fn().mockResolvedValue(mockFlagArr.length), From 71a529fb98e067fd8f28aa846081b1ba0744382d Mon Sep 17 00:00:00 2001 From: Zack Lee Date: Thu, 17 Sep 2026 16:54:43 -0400 Subject: [PATCH 52/61] fix: handle detail views during batch deletion --- .../batch-actions.integration.spec.ts | 48 ++++++++++++++++++- .../store/experiments.selectors.ts | 5 +- .../store/feature-flags.selectors.ts | 5 +- .../core/segments/store/segments.selectors.ts | 5 +- .../dashboard/batch-delete-ui.spec.ts | 17 ++++++- ...ent-root-section-card-table.component.html | 3 +- ...lag-root-section-card-table.component.html | 3 +- ...ent-root-section-card-table.component.html | 3 +- 8 files changed, 80 insertions(+), 9 deletions(-) diff --git a/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.integration.spec.ts b/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.integration.spec.ts index 788a87f7a..d84746d23 100644 --- a/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.integration.spec.ts +++ b/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.integration.spec.ts @@ -18,8 +18,10 @@ import * as segmentActions from '../segments/store/segments.actions'; import { experimentsReducer } from '../experiments/store/experiments.reducer'; import { featureFlagsReducer } from '../feature-flags/store/feature-flags.reducer'; import { segmentsReducer } from '../segments/store/segments.reducer'; -import { selectSelectedExperiment } from '../experiments/store/experiments.selectors'; -import { selectSelectedSegment } from '../segments/store/segments.selectors'; +import { selectExperimentDetailsPageError, selectSelectedExperiment } from '../experiments/store/experiments.selectors'; +import { selectSegmentDetailsPageError, selectSelectedSegment } from '../segments/store/segments.selectors'; +import { selectFeatureFlagDetailsPageError } from '../feature-flags/store/feature-flags.selectors'; +import { PAGE_ERROR_TYPE } from '@shared-component-lib/common-page-error/common-page-error.model'; import { ExperimentEffects } from '../experiments/store/experiments.effects'; import { FeatureFlagsEffects } from '../feature-flags/store/feature-flags.effects'; import { FeatureFlagsService } from '../feature-flags/feature-flags.service'; @@ -586,6 +588,48 @@ describe.each(fixtures)('$entity batch store/effects integration', (config) => { } ); + it('shows the existing not-found state for a deleted detail, including a late detail response', () => { + selectRows(1); + const snapshot = prepare(); + store.dispatch(actions.batchDeleteRequested({ snapshot })); + const viewed = rows[0]; + router.url = `${config.rootPath}/detail/${viewed.id}`; + store.dispatch(actions.rootPageLeft()); + const detailLoaded = + config.entity === 'experiments' + ? experimentActions.actionGetExperimentByIdSuccess({ experiment: viewed as any }) + : config.entity === 'flags' + ? flagActions.actionFetchFeatureFlagByIdSuccess({ flag: viewed as any }) + : segmentActions.actionGetSegmentByIdSuccess({ + segment: viewed as any, + experimentSegmentInclusion: [], + experimentSegmentExclusion: [], + featureFlagSegmentInclusion: [], + featureFlagSegmentExclusion: [], + allParentSegments: [], + }); + const errorSelector = + config.entity === 'experiments' + ? selectExperimentDetailsPageError + : config.entity === 'flags' + ? selectFeatureFlagDetailsPageError + : selectSegmentDetailsPageError; + const detailsError = (id = viewed.id) => + errorSelector.projector( + { state: { params: { experimentId: id, flagId: id, segmentId: id } } } as any, + state[config.key] + ); + store.dispatch(detailLoaded); + expect(detailsError()).toBeNull(); + response.next({ phase: 'executed', results: [{ id: viewed.id, outcome: 'deleted' }] }); + const notFound = { entityId: viewed.id, errorType: PAGE_ERROR_TYPE.NOT_FOUND }; + expect(detailsError()).toEqual(notFound); + store.dispatch(detailLoaded); + expect(detailsError()).toEqual(notFound); + expect(detailsError(rows[1].id)).toBeNull(); + expect(router.navigate).not.toHaveBeenCalled(); + }); + if (config.entity !== 'flags') { it.each(['deletion', 'refresh'])('preserves open details when leaving during %s', (pending) => { selectRows(1); diff --git a/packages/frontend/projects/upgrade/src/app/core/experiments/store/experiments.selectors.ts b/packages/frontend/projects/upgrade/src/app/core/experiments/store/experiments.selectors.ts index 4212985ef..db02ec46e 100644 --- a/packages/frontend/projects/upgrade/src/app/core/experiments/store/experiments.selectors.ts +++ b/packages/frontend/projects/upgrade/src/app/core/experiments/store/experiments.selectors.ts @@ -28,7 +28,7 @@ import { import { determineWeightingMethod, isWeightSumValid } from '../condition-helper.service'; import { formatTSConfigurablePolicyParamDetails } from '../mooclet-helper.service'; import { KeyValueFormat } from '@shared-component-lib/common-section-card-overview-details/common-section-card-overview-details.component'; -import { DetailsPageError } from '@shared-component-lib/common-page-error/common-page-error.model'; +import { DetailsPageError, PAGE_ERROR_TYPE } from '@shared-component-lib/common-page-error/common-page-error.model'; import { getExperimentDeletionReason } from '../../batch-actions/batch-actions.helpers'; export const selectExperimentState = createFeatureSelector('experiments'); @@ -91,6 +91,9 @@ export const selectExperimentDetailsPageError = createSelector( const experimentId = routerState?.state?.params?.experimentId; const detailsPageError = experimentState?.detailsPageError; + if (experimentState?.rootBatch?.removedIds.includes(experimentId)) + return { entityId: experimentId, errorType: PAGE_ERROR_TYPE.NOT_FOUND }; + // Only surface the error if it belongs to the experiment currently in the route return detailsPageError && detailsPageError.entityId === experimentId ? detailsPageError : null; } diff --git a/packages/frontend/projects/upgrade/src/app/core/feature-flags/store/feature-flags.selectors.ts b/packages/frontend/projects/upgrade/src/app/core/feature-flags/store/feature-flags.selectors.ts index bd9a247ad..cdef42a44 100644 --- a/packages/frontend/projects/upgrade/src/app/core/feature-flags/store/feature-flags.selectors.ts +++ b/packages/frontend/projects/upgrade/src/app/core/feature-flags/store/feature-flags.selectors.ts @@ -1,5 +1,5 @@ import { createSelector, createFeatureSelector } from '@ngrx/store'; -import { DetailsPageError } from '@shared-component-lib/common-page-error/common-page-error.model'; +import { DetailsPageError, PAGE_ERROR_TYPE } from '@shared-component-lib/common-page-error/common-page-error.model'; import { FeatureFlag, FeatureFlagState, ParticipantListTableRow } from './feature-flags.model'; import { selectRouterState } from '../../core.state'; import { selectContextMetaData } from '../../experiments/store/experiments.selectors'; @@ -78,6 +78,9 @@ export const selectFeatureFlagDetailsPageError = createSelector( const flagId = routerState?.state?.params?.flagId; const detailsPageError = featureFlagState?.detailsPageError; + if (featureFlagState?.rootBatch?.removedIds.includes(flagId)) + return { entityId: flagId, errorType: PAGE_ERROR_TYPE.NOT_FOUND }; + // Only surface the error if it belongs to the feature flag currently in the route return detailsPageError && detailsPageError.entityId === flagId ? detailsPageError : null; } diff --git a/packages/frontend/projects/upgrade/src/app/core/segments/store/segments.selectors.ts b/packages/frontend/projects/upgrade/src/app/core/segments/store/segments.selectors.ts index 0b393aa83..a140cfc83 100644 --- a/packages/frontend/projects/upgrade/src/app/core/segments/store/segments.selectors.ts +++ b/packages/frontend/projects/upgrade/src/app/core/segments/store/segments.selectors.ts @@ -1,5 +1,5 @@ import { createSelector, createFeatureSelector } from '@ngrx/store'; -import { DetailsPageError } from '@shared-component-lib/common-page-error/common-page-error.model'; +import { DetailsPageError, PAGE_ERROR_TYPE } from '@shared-component-lib/common-page-error/common-page-error.model'; import { SegmentState, ParticipantListTableRow, @@ -108,6 +108,9 @@ export const selectSegmentDetailsPageError = createSelector( const segmentId = routerState?.state?.params?.segmentId; const detailsPageError = segmentState?.detailsPageError; + if (segmentState?.rootBatch?.removedIds.includes(segmentId)) + return { entityId: segmentId, errorType: PAGE_ERROR_TYPE.NOT_FOUND }; + // Only surface the error if it belongs to the segment currently in the route return detailsPageError && detailsPageError.entityId === segmentId ? detailsPageError : null; } diff --git a/packages/frontend/projects/upgrade/src/app/features/dashboard/batch-delete-ui.spec.ts b/packages/frontend/projects/upgrade/src/app/features/dashboard/batch-delete-ui.spec.ts index 004bc82b7..a81a528d1 100644 --- a/packages/frontend/projects/upgrade/src/app/features/dashboard/batch-delete-ui.spec.ts +++ b/packages/frontend/projects/upgrade/src/app/features/dashboard/batch-delete-ui.spec.ts @@ -1,7 +1,7 @@ import { ComponentFixture, TestBed, fakeAsync, tick } from '@angular/core/testing'; import { By } from '@angular/platform-browser'; import { NoopAnimationsModule } from '@angular/platform-browser/animations'; -import { provideRouter } from '@angular/router'; +import { provideRouter, Router } from '@angular/router'; import { OverlayContainer } from '@angular/cdk/overlay'; import { MatDialog } from '@angular/material/dialog'; import { MatTooltip } from '@angular/material/tooltip'; @@ -456,8 +456,11 @@ describe.each(cases)('$entity root batch UI', (config) => { it.each(['list', 'deletion'])('keeps the existing progress bar until both requests finish (%s first)', (first) => { const progressBar = () => fixture.nativeElement.querySelector('mat-progress-bar'); + const nameLinks = (): HTMLAnchorElement[] => [...fixture.nativeElement.querySelectorAll('td.name-column a')]; + const navigate = jest.spyOn(TestBed.inject(Router), 'navigateByUrl').mockResolvedValue(true); selectFirst(); expect(progressBar()).toBeNull(); + expect(nameLinks().every((link) => link.hasAttribute('href'))).toBe(true); store.dispatch(actions.prepareConfirmation({ operationId: 'pending-delete' })); store.dispatch(actions.batchDeleteRequested({ snapshot: batch().confirmation })); fixture.detectChanges(); @@ -465,6 +468,12 @@ describe.each(cases)('$entity root batch UI', (config) => { const trigger = fixture.debugElement.query(By.css('.section-card-menu-trigger')); expect(trigger.nativeElement.disabled).toBe(true); expect(trigger.parent.injector.get(MatTooltip).message).toBe(''); + for (const link of nameLinks()) { + expect(link.hasAttribute('href')).toBe(false); + expect(link.getAttribute('aria-disabled')).toBe('true'); + link.click(); + } + expect(navigate).not.toHaveBeenCalled(); listLoading$.next(true); const finishDeletion = () => @@ -478,11 +487,16 @@ describe.each(cases)('$entity root batch UI', (config) => { else finishDeletion(); fixture.detectChanges(); expect(progressBar()).not.toBeNull(); + expect(nameLinks().every((link) => link.hasAttribute('href'))).toBe(first === 'deletion'); if (first === 'list') finishDeletion(); else listLoading$.next(false); fixture.detectChanges(); expect(progressBar()).toBeNull(); + expect(nameLinks().every((link) => link.hasAttribute('href') && !link.hasAttribute('aria-disabled'))).toBe(true); + nameLinks()[0].click(); + expect(navigate).toHaveBeenCalledTimes(1); + navigate.mockRestore(); }); it('keeps checkboxes usable without a banner or reload button after request and refresh failures', () => { @@ -495,6 +509,7 @@ describe.each(cases)('$entity root batch UI', (config) => { store.dispatch(actions.batchDeleteRequestFailed({ operationId: 'offline-delete', status: 0 })); fixture.detectChanges(); expect(checkboxes().every((input) => !input.disabled)).toBe(true); + expect(fixture.nativeElement.querySelector('td.name-column a').hasAttribute('href')).toBe(true); expect(fixture.nativeElement.querySelector('mat-progress-bar')).toBeNull(); checkboxes()[1].click(); fixture.detectChanges(); diff --git a/packages/frontend/projects/upgrade/src/app/features/dashboard/experiments/pages/experiment-root-page/experiment-root-page-content/experiment-root-section-card/experiment-root-section-card-table/experiment-root-section-card-table.component.html b/packages/frontend/projects/upgrade/src/app/features/dashboard/experiments/pages/experiment-root-page/experiment-root-page-content/experiment-root-section-card/experiment-root-section-card-table/experiment-root-section-card-table.component.html index 48cfd1d57..be29c1e21 100644 --- a/packages/frontend/projects/upgrade/src/app/features/dashboard/experiments/pages/experiment-root-page/experiment-root-page-content/experiment-root-section-card/experiment-root-section-card-table/experiment-root-section-card-table.component.html +++ b/packages/frontend/projects/upgrade/src/app/features/dashboard/experiments/pages/experiment-root-page/experiment-root-page-content/experiment-root-section-card/experiment-root-section-card-table/experiment-root-section-card-table.component.html @@ -73,7 +73,8 @@
+ @if (canSelect) { + }
; @Input() isSearchActive$: Observable; @Input() expandedTagsMap: Map; + @Input() canSelect = false; @Output() tagsExpanded = new EventEmitter<{ segmentId: string; expanded: boolean }>(); segmentSortKey$ = this.segmentsService.selectSegmentSortKey$; segmentSortAs$ = this.segmentsService.selectSegmentSortAs$; diff --git a/packages/frontend/projects/upgrade/src/app/features/dashboard/segments/pages/segment-root-page/segment-root-page-content/segment-root-section-card/segment-root-section-card.component.html b/packages/frontend/projects/upgrade/src/app/features/dashboard/segments/pages/segment-root-page/segment-root-page-content/segment-root-section-card/segment-root-section-card.component.html index bfac77750..9fc27e17c 100644 --- a/packages/frontend/projects/upgrade/src/app/features/dashboard/segments/pages/segment-root-page/segment-root-page-content/segment-root-section-card/segment-root-section-card.component.html +++ b/packages/frontend/projects/upgrade/src/app/features/dashboard/segments/pages/segment-root-page/segment-root-page-content/segment-root-section-card/segment-root-section-card.component.html @@ -40,6 +40,7 @@ } @else {
Date: Wed, 16 Sep 2026 13:51:19 -0400 Subject: [PATCH 32/61] docs: restore existing single-delete API descriptions --- packages/backend/src/api/controllers/ExperimentController.ts | 2 +- packages/backend/src/api/controllers/FeatureFlagController.ts | 2 -- packages/backend/src/api/controllers/SegmentController.ts | 4 +--- 3 files changed, 2 insertions(+), 6 deletions(-) diff --git a/packages/backend/src/api/controllers/ExperimentController.ts b/packages/backend/src/api/controllers/ExperimentController.ts index fd9e51c56..661a5998b 100644 --- a/packages/backend/src/api/controllers/ExperimentController.ts +++ b/packages/backend/src/api/controllers/ExperimentController.ts @@ -1178,7 +1178,7 @@ export class ExperimentController { * schema: * $ref: '#/definitions/ExperimentResponse' * '400': - * description: Invalid UUID. + * description: ExperimentId should be a valid UUID. * '401': * description: AuthorizationRequiredError * '404': diff --git a/packages/backend/src/api/controllers/FeatureFlagController.ts b/packages/backend/src/api/controllers/FeatureFlagController.ts index 8acd4a88d..3ab196e05 100644 --- a/packages/backend/src/api/controllers/FeatureFlagController.ts +++ b/packages/backend/src/api/controllers/FeatureFlagController.ts @@ -466,8 +466,6 @@ export class FeatureFlagsController { * responses: * '200': * description: Delete Feature flag By Id - * '400': - * description: Invalid UUID * '404': * description: Feature flag not found */ diff --git a/packages/backend/src/api/controllers/SegmentController.ts b/packages/backend/src/api/controllers/SegmentController.ts index ea5b165b3..9560d32d5 100644 --- a/packages/backend/src/api/controllers/SegmentController.ts +++ b/packages/backend/src/api/controllers/SegmentController.ts @@ -670,9 +670,7 @@ export class SegmentController { * '401': * description: Authorization Required Error * '500': - * description: Internal Server Error - * '400': - * description: Invalid UUID + * description: Internal Server Error, SegmentId is not valid */ @Delete('/:segmentId') public deleteSegment( From 6dd78bf19548b2efe8a4c3c64e9dd7c9adc1152b Mon Sep 17 00:00:00 2001 From: Zack Lee Date: Wed, 16 Sep 2026 14:50:16 -0400 Subject: [PATCH 33/61] fix: remove reactive batch selection invalidation --- .../batch-actions.integration.spec.ts | 37 ++++++++++--------- .../batch-actions/batch-actions.models.ts | 4 +- .../batch-actions/batch-actions.reducer.ts | 13 +------ .../core/batch-actions/batch-actions.store.ts | 9 ----- .../dashboard/batch-delete-ui.spec.ts | 4 +- .../batch-confirmation.spec.ts | 15 +------- .../root-batch-actions.directive.ts | 2 +- .../shared/services/common-dialog.service.ts | 6 +-- 8 files changed, 28 insertions(+), 62 deletions(-) diff --git a/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.integration.spec.ts b/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.integration.spec.ts index f6475a5fa..e3bc51729 100644 --- a/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.integration.spec.ts +++ b/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.integration.spec.ts @@ -294,7 +294,7 @@ describe.each(fixtures)('$entity batch store/effects integration', (config) => { expect(data.batchDelete).not.toHaveBeenCalled(); }); - it('uses updated page data to block a hidden restriction without an eligibility request', () => { + it('keeps selection-time availability when refreshed rows change state, including hidden selections', () => { selectRows(); const blocked = { ...rows[2], @@ -305,12 +305,13 @@ describe.each(fixtures)('$entity batch store/effects integration', (config) => { store.dispatch(config.fetch({ fromStarting: true })); data[config.fetchMethod].mockReturnValueOnce(of(page([rows[0]]))); store.dispatch(config.fetch({ fromStarting: true })); - store.dispatch(actions.prepareConfirmation({ operationId: newBatchRequestId() })); - expect(batch().confirmation).toBeNull(); + const snapshot = prepare(); expect(Object.keys(batch().selectedById)).toHaveLength(3); - expect(selectionView(batch(), config.entity).canRequestConfirmation).toBe(false); + expect(batch().selectedById[blocked.id]).toEqual(selectionItem(rows[2])); + expect(selectionView(batch(), config.entity).canRequestConfirmation).toBe(true); - expect(data.batchDelete).not.toHaveBeenCalled(); + store.dispatch(actions.batchDeleteRequested({ snapshot })); + expect(data.batchDelete).toHaveBeenCalledWith(rows.map(({ id }) => id)); }); it('retains cached selections until the delete response establishes an absence', () => { @@ -404,13 +405,24 @@ describe.each(fixtures)('$entity batch store/effects integration', (config) => { it('keeps the displayed confirmation snapshot when updated rows arrive or focus returns', fakeAsync(() => { selectRows(2); const snapshot = prepare(); - data[config.fetchMethod].mockReturnValueOnce(of(page([{ ...rows[0], name: 'renamed' }]))); + data[config.fetchMethod].mockReturnValueOnce( + of( + page([ + { + ...rows[0], + name: 'renamed', + state: config.entity === 'experiments' ? EXPERIMENT_STATE.RUNNING : undefined, + status: config.entity === 'segments' ? SEGMENT_STATUS.USED : FEATURE_FLAG_STATUS.ENABLED, + }, + ]) + ) + ); store.dispatch(config.fetch({ fromStarting: true })); window.dispatchEvent(new Event('focus')); tick(100); expect(batch().confirmation).toBe(snapshot); expect(snapshot.items.map(({ name }) => name)).toEqual(['a', 'b']); - expect(batch().selectedById[rows[0].id].name).toBe('renamed'); + expect(batch().selectedById[rows[0].id]).toEqual(selectionItem(rows[0])); store.dispatch(actions.batchDeleteRequested({ snapshot })); expect(data.batchDelete).toHaveBeenCalledWith([rows[0].id, rows[1].id]); @@ -644,15 +656,4 @@ describe.each(fixtures)('$entity batch store/effects integration', (config) => { expect(batch().removedIds).toHaveLength(0); expect(notifications.showSuccess).not.toHaveBeenCalled(); }); - - it('clears selection and prevents submission of the old confirmation when the role changes', () => { - selectRows(); - const snapshot = prepare(); - store.dispatch(actionSetUserInfo({ user: { email: 'review@example.com', role: UserRole.READER } })); - expect(Object.keys(batch().selectedById)).toHaveLength(0); - expect(batch().confirmation).toBeNull(); - store.dispatch(actions.batchDeleteRequested({ snapshot })); - expect(data.batchDelete).not.toHaveBeenCalled(); - expect(batch().operation).toBeNull(); - }); }); diff --git a/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.models.ts b/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.models.ts index f9a6bb6af..d784df245 100644 --- a/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.models.ts +++ b/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.models.ts @@ -1,4 +1,4 @@ -import { BatchDeleteResult, EXPERIMENT_STATE, FEATURE_FLAG_STATUS, SEGMENT_STATUS, UserRole } from 'upgrade_types'; +import { BatchDeleteResult, EXPERIMENT_STATE, FEATURE_FLAG_STATUS, SEGMENT_STATUS } from 'upgrade_types'; export enum BatchSelectionReasonCode { EXPERIMENT_ACTIVE = 'experiment_active', @@ -20,7 +20,6 @@ export interface BatchDeleteSnapshot { export interface RootBatchState { selectedById: Record; userEmail: string | null; - role: UserRole | null; loadedIds: string[]; listRequestId: string | null; listLoading: boolean; @@ -37,7 +36,6 @@ export interface RootBatchState { export const initialRootBatchState: RootBatchState = { selectedById: {}, userEmail: null, - role: null, loadedIds: [], listRequestId: null, listLoading: false, diff --git a/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.reducer.ts b/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.reducer.ts index f4a27ab68..539d3757f 100644 --- a/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.reducer.ts +++ b/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.reducer.ts @@ -34,16 +34,9 @@ export function receiveListRows( ): RootBatchState { const removed = new Set(state.removedIds); const rows = items.filter((item) => !removed.has(item.id)); - const selectedById = { ...state.selectedById }; - rows.forEach((item) => { - if (selectedById[item.id]) { - selectedById[item.id] = item; - } - }); return { - // A background page refresh must not replace the snapshot already shown in the dialog. + // Keep selection-time metadata when searches or later pages update the displayed rows. ...state, - selectedById, loadedIds: [...new Set([...(fromStarting ? [] : state.loadedIds), ...rows.map((item) => item.id)])], listLoading: false, }; @@ -63,9 +56,7 @@ export function reduceRootBatch( matches(action, auth.actionLoginSuccess) ) { const email = action.user?.email || null; - const role = action.user?.role || null; - if (state.userEmail !== email) return { ...initialRootBatchState, userEmail: email, role }; - return state.role === role ? state : { ...invalidateSelection(state), selectedById: {}, role }; + return state.userEmail === email ? state : { ...initialRootBatchState, userEmail: email }; } if (matches(action, actions.listRequested)) return { diff --git a/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.store.ts b/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.store.ts index 28af67997..23c105e48 100644 --- a/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.store.ts +++ b/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.store.ts @@ -41,15 +41,6 @@ export function withRootBatch( const next = reducer(state, action); if (action.type === config.listSuccessType) { rootBatch = receiveListRows(rootBatch, action[config.responseRowsKey].map(selectionItem), !!action.fromStarting); - } else if (next[config.rowsKey] !== state[config.rowsKey]) { - const previousRows = new Map(state[config.rowsKey].map((row) => [row.id, row])); - const changed = next[config.rowsKey].filter( - (row) => previousRows.get(row.id) !== row && rootBatch.selectedById[row.id] - ); - if (changed.length) { - const updated = receiveListRows(rootBatch, changed.map(selectionItem), false); - rootBatch = { ...updated, loadedIds: rootBatch.loadedIds, listLoading: rootBatch.listLoading }; - } } // Tombstones also protect against late detail/stat responses, whose IDs are not tied to a root query. const removed = new Set(rootBatch.removedIds); diff --git a/packages/frontend/projects/upgrade/src/app/features/dashboard/batch-delete-ui.spec.ts b/packages/frontend/projects/upgrade/src/app/features/dashboard/batch-delete-ui.spec.ts index a6a1d7f83..0e386f5cd 100644 --- a/packages/frontend/projects/upgrade/src/app/features/dashboard/batch-delete-ui.spec.ts +++ b/packages/frontend/projects/upgrade/src/app/features/dashboard/batch-delete-ui.spec.ts @@ -364,8 +364,8 @@ describe.each(cases)('$entity root batch UI', (config) => { 'uses the common dialog and does not refocus the menu trigger after %s', fakeAsync((action) => { const realDialogs = new DialogService(TestBed.inject(MatDialog), TestBed.inject(TranslateService)); - dialogs.openBatchDeleteModal.mockImplementation((entity, snapshot, facade) => - realDialogs.openBatchDeleteModal(entity, snapshot, facade) + dialogs.openBatchDeleteModal.mockImplementation((entity, snapshot) => + realDialogs.openBatchDeleteModal(entity, snapshot) ); selectFirst(); openMenu(); diff --git a/packages/frontend/projects/upgrade/src/app/shared-standalone-component-lib/components/common-simple-text-validated-confirmation-modal/batch-confirmation.spec.ts b/packages/frontend/projects/upgrade/src/app/shared-standalone-component-lib/components/common-simple-text-validated-confirmation-modal/batch-confirmation.spec.ts index d1cbeb82d..6f9a4cfc4 100644 --- a/packages/frontend/projects/upgrade/src/app/shared-standalone-component-lib/components/common-simple-text-validated-confirmation-modal/batch-confirmation.spec.ts +++ b/packages/frontend/projects/upgrade/src/app/shared-standalone-component-lib/components/common-simple-text-validated-confirmation-modal/batch-confirmation.spec.ts @@ -3,9 +3,7 @@ import { NoopAnimationsModule } from '@angular/platform-browser/animations'; import { MatDialog, MatDialogModule, MatDialogRef } from '@angular/material/dialog'; import { OverlayContainer } from '@angular/cdk/overlay'; import { TranslateModule, TranslateService } from '@ngx-translate/core'; -import { BehaviorSubject } from 'rxjs'; import { BatchDeleteEntity } from 'upgrade_types'; -import { initialRootBatchState, RootBatchState } from '../../../core/batch-actions/batch-actions.models'; import { DialogService } from '../../../shared/services/common-dialog.service'; import { CommonSimpleTextValidatedConfirmationModalComponent } from './common-simple-text-validated-confirmation-modal.component'; @@ -13,7 +11,6 @@ const translations = jest.requireActual('../../../../assets/i18n/en.json'); describe('Batch deletion using the existing text confirmation dialog', () => { let ref: MatDialogRef; - let state$: BehaviorSubject; let container: HTMLElement; const primary = () => container.querySelector('.footer-container button:not(.cancel-btn)') as HTMLButtonElement; function detect() { @@ -25,8 +22,7 @@ describe('Batch deletion using the existing text confirmation dialog', () => { operationId: 'operation', items: Array.from({ length: count }, (_, index) => ({ id: String(index), name: `Item ${index}` })), }; - state$ = new BehaviorSubject({ ...initialRootBatchState, confirmation: snapshot }); - ref = TestBed.inject(DialogService).openBatchDeleteModal(entity, snapshot, { state$ } as any); + ref = TestBed.inject(DialogService).openBatchDeleteModal(entity, snapshot); detect(); } function input(value: string) { @@ -51,7 +47,6 @@ describe('Batch deletion using the existing text confirmation dialog', () => { }); afterEach(() => { TestBed.inject(MatDialog).closeAll(); - state$?.complete(); TestBed.resetTestingModule(); }); @@ -103,12 +98,4 @@ describe('Batch deletion using the existing text confirmation dialog', () => { tick(); expect(TestBed.inject(MatDialog).openDialogs).toHaveLength(1); })); - - it('disables confirmation when selection or permissions invalidate its snapshot', fakeAsync(() => { - open(); - input('delete'); - state$.next({ ...state$.value, confirmation: null }); - detect(); - expect(primary().disabled).toBe(true); - })); }); diff --git a/packages/frontend/projects/upgrade/src/app/shared/directives/root-batch-actions.directive.ts b/packages/frontend/projects/upgrade/src/app/shared/directives/root-batch-actions.directive.ts index 85470c9de..60f719be6 100644 --- a/packages/frontend/projects/upgrade/src/app/shared/directives/root-batch-actions.directive.ts +++ b/packages/frontend/projects/upgrade/src/app/shared/directives/root-batch-actions.directive.ts @@ -47,7 +47,7 @@ export class RootBatchActionsDirective implements OnInit, OnDestroy { this.batchFacade.state$.subscribe((state) => { state.removedIds.forEach((id) => this.batchExpandedTags?.delete(id)); if (!state.confirmation || this.dialogRef) return; - const ref = this.dialogs.openBatchDeleteModal(this.batchEntity, state.confirmation, this.batchFacade); + const ref = this.dialogs.openBatchDeleteModal(this.batchEntity, state.confirmation); const operationId = state.confirmation.operationId; this.dialogRef = ref; this.subscriptions.add( diff --git a/packages/frontend/projects/upgrade/src/app/shared/services/common-dialog.service.ts b/packages/frontend/projects/upgrade/src/app/shared/services/common-dialog.service.ts index b3c3a3662..46ca36b45 100644 --- a/packages/frontend/projects/upgrade/src/app/shared/services/common-dialog.service.ts +++ b/packages/frontend/projects/upgrade/src/app/shared/services/common-dialog.service.ts @@ -70,10 +70,9 @@ import { EditPayloadModalComponent, EditPayloadModalParams, } from '../../features/dashboard/experiments/modals/edit-payload-modal/edit-payload-modal.component'; -import { Observable, map } from 'rxjs'; +import { Observable } from 'rxjs'; import { TranslateService } from '@ngx-translate/core'; import { BatchDeleteEntity } from 'upgrade_types'; -import { BatchFacade } from '../../core/batch-actions/batch-actions.facade'; import { BatchDeleteSnapshot } from '../../core/batch-actions/batch-actions.models'; export interface ImportModalParams { @@ -113,7 +112,7 @@ export interface UpsertMetricModalParams { export class DialogService { constructor(private dialog: MatDialog, private translate: TranslateService) {} - openBatchDeleteModal(entity: BatchDeleteEntity, snapshot: BatchDeleteSnapshot, facade: BatchFacade) { + openBatchDeleteModal(entity: BatchDeleteEntity, snapshot: BatchDeleteSnapshot) { const config: CommonModalConfig = { title: `batch-delete.dialog.${entity}.title`, primaryActionBtnLabel: 'Delete', @@ -126,7 +125,6 @@ export class DialogService { ), validationKeyword: 'delete', validationPlaceholder: 'Type delete', - isLoading$: facade.state$.pipe(map((state) => state.confirmation?.operationId !== snapshot.operationId)), }, }; // Restoring focus here leaves the root menu trigger's focus circle visible after Cancel or Close. From 2ef23d2f456efc33df0721e3b3305bb91fa0500c Mon Sep 17 00:00:00 2001 From: Zack Lee Date: Wed, 16 Sep 2026 16:26:12 -0400 Subject: [PATCH 34/61] refactor: align batch deletion service and test locations with existing structure --- .../api/controllers/ExperimentController.ts | 2 +- .../api/controllers/FeatureFlagController.ts | 2 +- .../src/api/controllers/SegmentController.ts | 2 +- .../{batch => }/BatchDeleteService.ts | 22 +++++++++---------- .../test/integration/BatchDelete/index.ts | 2 +- .../controllers/ExperimentController.test.ts | 2 +- .../controllers/FeatureFlagController.test.ts | 2 +- .../controllers/SegmentController.test.ts | 2 +- .../{batch => }/BatchDeleteService.test.ts | 18 +++++++-------- 9 files changed, 27 insertions(+), 27 deletions(-) rename packages/backend/src/api/services/{batch => }/BatchDeleteService.ts (91%) rename packages/backend/test/unit/services/{batch => }/BatchDeleteService.test.ts (94%) diff --git a/packages/backend/src/api/controllers/ExperimentController.ts b/packages/backend/src/api/controllers/ExperimentController.ts index 661a5998b..9b8f3f524 100644 --- a/packages/backend/src/api/controllers/ExperimentController.ts +++ b/packages/backend/src/api/controllers/ExperimentController.ts @@ -1,6 +1,6 @@ import { BatchDeleteResult } from 'upgrade_types'; import { Inject } from 'typedi'; -import { BatchDeleteService } from '../services/batch/BatchDeleteService'; +import { BatchDeleteService } from '../services/BatchDeleteService'; import { BatchEntityIdsValidator } from './validators/BatchEntityIdsValidator'; import { Body, diff --git a/packages/backend/src/api/controllers/FeatureFlagController.ts b/packages/backend/src/api/controllers/FeatureFlagController.ts index 3ab196e05..0144678e1 100644 --- a/packages/backend/src/api/controllers/FeatureFlagController.ts +++ b/packages/backend/src/api/controllers/FeatureFlagController.ts @@ -1,6 +1,6 @@ import { BatchDeleteResult } from 'upgrade_types'; import { Inject } from 'typedi'; -import { BatchDeleteService } from '../services/batch/BatchDeleteService'; +import { BatchDeleteService } from '../services/BatchDeleteService'; import { BatchEntityIdsValidator } from './validators/BatchEntityIdsValidator'; import { JsonController, diff --git a/packages/backend/src/api/controllers/SegmentController.ts b/packages/backend/src/api/controllers/SegmentController.ts index 9560d32d5..7f941dca5 100644 --- a/packages/backend/src/api/controllers/SegmentController.ts +++ b/packages/backend/src/api/controllers/SegmentController.ts @@ -1,7 +1,7 @@ import { UserDTO } from '../DTO/UserDTO'; import { BatchDeleteResult } from 'upgrade_types'; import { Inject } from 'typedi'; -import { BatchDeleteService } from '../services/batch/BatchDeleteService'; +import { BatchDeleteService } from '../services/BatchDeleteService'; import { BatchEntityIdsValidator } from './validators/BatchEntityIdsValidator'; import { JsonController, diff --git a/packages/backend/src/api/services/batch/BatchDeleteService.ts b/packages/backend/src/api/services/BatchDeleteService.ts similarity index 91% rename from packages/backend/src/api/services/batch/BatchDeleteService.ts rename to packages/backend/src/api/services/BatchDeleteService.ts index 01f03750f..f4c64f7d8 100644 --- a/packages/backend/src/api/services/batch/BatchDeleteService.ts +++ b/packages/backend/src/api/services/BatchDeleteService.ts @@ -9,17 +9,17 @@ import { DeletionReasonCode, hasBatchDeletePermission, } from 'upgrade_types'; -import { env } from '../../../env'; -import { UpgradeLogger } from '../../../lib/logger/UpgradeLogger'; -import { InjectDataSource, InjectRepository } from '../../../typeorm-typedi-extensions'; -import { DeletionTransaction } from '../../../types/DeletionTransaction'; -import { UserDTO } from '../../DTO/UserDTO'; -import { MoocletError } from '../../errors/MoocletError'; -import { ExperimentService } from '../ExperimentService'; -import { FeatureFlagService } from '../FeatureFlagService'; -import { MoocletExperimentService } from '../MoocletExperimentService'; -import { SegmentService } from '../SegmentService'; -import { DeletionRepository } from '../../repositories/DeletionRepository'; +import { env } from '../../env'; +import { UpgradeLogger } from '../../lib/logger/UpgradeLogger'; +import { InjectDataSource, InjectRepository } from '../../typeorm-typedi-extensions'; +import { DeletionTransaction } from '../../types/DeletionTransaction'; +import { UserDTO } from '../DTO/UserDTO'; +import { MoocletError } from '../errors/MoocletError'; +import { ExperimentService } from './ExperimentService'; +import { FeatureFlagService } from './FeatureFlagService'; +import { MoocletExperimentService } from './MoocletExperimentService'; +import { SegmentService } from './SegmentService'; +import { DeletionRepository } from '../repositories/DeletionRepository'; // Admission budget: never abandon an in-flight deletion or claim it has been cancelled. // Check before starting another item and again after acquiring its target lock. diff --git a/packages/backend/test/integration/BatchDelete/index.ts b/packages/backend/test/integration/BatchDelete/index.ts index 999032fcd..c1f8474aa 100644 --- a/packages/backend/test/integration/BatchDelete/index.ts +++ b/packages/backend/test/integration/BatchDelete/index.ts @@ -38,7 +38,7 @@ import { ExperimentPrecomputedSegmentService } from '../../../src/api/services/E import { FeatureFlagPrecomputedSegmentService } from '../../../src/api/services/FeatureFlagPrecomputedSegmentService'; import { MoocletExperimentService } from '../../../src/api/services/MoocletExperimentService'; import { SegmentService } from '../../../src/api/services/SegmentService'; -import { BatchDeleteService } from '../../../src/api/services/batch/BatchDeleteService'; +import { BatchDeleteService } from '../../../src/api/services/BatchDeleteService'; import { currentUserChecker } from '../../../src/auth/currentUserChecker'; import { env } from '../../../src/env'; import { iocLoader } from '../../../src/loaders/iocLoader'; diff --git a/packages/backend/test/unit/controllers/ExperimentController.test.ts b/packages/backend/test/unit/controllers/ExperimentController.test.ts index 96138b37e..72c9f3ca3 100644 --- a/packages/backend/test/unit/controllers/ExperimentController.test.ts +++ b/packages/backend/test/unit/controllers/ExperimentController.test.ts @@ -1,4 +1,4 @@ -import { BatchDeleteService } from '../../../src/api/services/batch/BatchDeleteService'; +import { BatchDeleteService } from '../../../src/api/services/BatchDeleteService'; import app from '../../utils/expressApp'; import request from 'supertest'; import { configureLogger } from '../../utils/logger'; diff --git a/packages/backend/test/unit/controllers/FeatureFlagController.test.ts b/packages/backend/test/unit/controllers/FeatureFlagController.test.ts index 956ff305f..3cfc4084b 100644 --- a/packages/backend/test/unit/controllers/FeatureFlagController.test.ts +++ b/packages/backend/test/unit/controllers/FeatureFlagController.test.ts @@ -1,4 +1,4 @@ -import { BatchDeleteService } from '../../../src/api/services/batch/BatchDeleteService'; +import { BatchDeleteService } from '../../../src/api/services/BatchDeleteService'; import app from '../../utils/expressApp'; import request from 'supertest'; import { configureLogger } from '../../utils/logger'; diff --git a/packages/backend/test/unit/controllers/SegmentController.test.ts b/packages/backend/test/unit/controllers/SegmentController.test.ts index 6b9ee6dbc..9c7436ad7 100644 --- a/packages/backend/test/unit/controllers/SegmentController.test.ts +++ b/packages/backend/test/unit/controllers/SegmentController.test.ts @@ -1,4 +1,4 @@ -import { BatchDeleteService } from '../../../src/api/services/batch/BatchDeleteService'; +import { BatchDeleteService } from '../../../src/api/services/BatchDeleteService'; import app from '../../utils/expressApp'; import request from 'supertest'; import { configureLogger } from '../../utils/logger'; diff --git a/packages/backend/test/unit/services/batch/BatchDeleteService.test.ts b/packages/backend/test/unit/services/BatchDeleteService.test.ts similarity index 94% rename from packages/backend/test/unit/services/batch/BatchDeleteService.test.ts rename to packages/backend/test/unit/services/BatchDeleteService.test.ts index 85c1ee556..92811c7ae 100644 --- a/packages/backend/test/unit/services/batch/BatchDeleteService.test.ts +++ b/packages/backend/test/unit/services/BatchDeleteService.test.ts @@ -2,15 +2,15 @@ import { randomUUID } from 'crypto'; import { performance } from 'perf_hooks'; import { DataSource, EntityManager, QueryRunner } from 'typeorm'; import { BatchDeleteEntity, DeletionReasonCode, UserRole } from 'upgrade_types'; -import { BatchDeleteService } from '../../../../src/api/services/batch/BatchDeleteService'; -import { ExperimentService } from '../../../../src/api/services/ExperimentService'; -import { FeatureFlagService } from '../../../../src/api/services/FeatureFlagService'; -import { SegmentService } from '../../../../src/api/services/SegmentService'; -import { MoocletExperimentService } from '../../../../src/api/services/MoocletExperimentService'; -import { MoocletError } from '../../../../src/api/errors/MoocletError'; -import { UpgradeLogger } from '../../../../src/lib/logger/UpgradeLogger'; -import { env } from '../../../../src/env'; -import { DeletionRepository } from '../../../../src/api/repositories/DeletionRepository'; +import { BatchDeleteService } from '../../../src/api/services/BatchDeleteService'; +import { ExperimentService } from '../../../src/api/services/ExperimentService'; +import { FeatureFlagService } from '../../../src/api/services/FeatureFlagService'; +import { SegmentService } from '../../../src/api/services/SegmentService'; +import { MoocletExperimentService } from '../../../src/api/services/MoocletExperimentService'; +import { MoocletError } from '../../../src/api/errors/MoocletError'; +import { UpgradeLogger } from '../../../src/lib/logger/UpgradeLogger'; +import { env } from '../../../src/env'; +import { DeletionRepository } from '../../../src/api/repositories/DeletionRepository'; jest.mock('perf_hooks', () => ({ performance: { now: jest.fn(() => 0) } })); From 3ae20207d1433d58e813a0e014f8d8cd181d8147 Mon Sep 17 00:00:00 2001 From: Zack Lee Date: Wed, 16 Sep 2026 17:24:27 -0400 Subject: [PATCH 35/61] fix: classify batch deletion notifications by confirmed results --- .../batch-actions/batch-actions.helpers.ts | 21 +++++--- .../batch-actions.integration.spec.ts | 52 ++++++++++++++++++- .../experiments/store/experiments.effects.ts | 6 +-- .../store/feature-flags.effects.ts | 6 +-- .../core/segments/store/segments.effects.ts | 6 +-- .../projects/upgrade/src/assets/i18n/en.json | 3 +- 6 files changed, 73 insertions(+), 21 deletions(-) diff --git a/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.helpers.ts b/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.helpers.ts index f5506cfbe..91770055a 100644 --- a/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.helpers.ts +++ b/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.helpers.ts @@ -87,7 +87,7 @@ export function batchResultCounts(state: RootBatchState) { uncertain: results.some((item) => item.outcome === 'unknown'), failed: results.filter((item) => item.outcome === 'failed').length, notAttempted: results.filter((item) => item.outcome === 'not_attempted').length, - hasErrors: results.some((item) => item.outcome !== 'deleted' || item.reasonCode), + hasErrors: results.some((item) => item.outcome !== 'not_found' && (item.outcome !== 'deleted' || item.reasonCode)), postDeleteFailed: results.some((item) => item.reasonCode === DeletionReasonCode.POST_DELETE_FAILED), }; } @@ -99,16 +99,21 @@ export function batchResultMessage( translate: (key: string, params?: Record) => string ): string { const parts: string[] = []; - if (counts.deleted || !counts.hasErrors) + if (counts.deleted) parts.push( translate(`batch-delete.success.${entity}.${counts.deleted === 1 ? 'one' : 'other'}`, { deleted: counts.deleted }) ); - for (const key of ['absent', 'failed', 'notAttempted'] as const) { - if (counts[key]) - parts.push( - translate(`batch-delete.result.${key}.${counts[key] === 1 ? 'one' : 'other'}`, { count: counts[key] }) - ); - } + if (counts.absent) + parts.push( + translate(`batch-delete.result.absent.${counts.absent === 1 ? 'one' : 'other'}`, { count: counts.absent }) + ); + if (counts.failed) parts.push(translate('batch-delete.result.failed')); + if (counts.notAttempted) + parts.push( + translate(`batch-delete.result.notAttempted.${counts.notAttempted === 1 ? 'one' : 'other'}`, { + count: counts.notAttempted, + }) + ); if (counts.postDeleteFailed) parts.push(translate('batch-delete.result.post-delete-failed')); if (counts.uncertain) parts.push(translate('batch-delete.result.uncertain')); return parts.join(' '); diff --git a/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.integration.spec.ts b/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.integration.spec.ts index e3bc51729..2a83fa2da 100644 --- a/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.integration.spec.ts +++ b/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.integration.spec.ts @@ -332,6 +332,46 @@ describe.each(fixtures)('$entity batch store/effects integration', (config) => { expect(batch().confirmation).toBeNull(); }); + it.each([0, 1])('reports success when %i of two items are deleted and the rest were already absent', (deleted) => { + selectRows(2); + const snapshot = prepare(); + store.dispatch(actions.batchDeleteRequested({ snapshot })); + response.next({ + phase: deleted ? 'executed' : 'rejected', + results: rows + .slice(0, 2) + .map(({ id }, index) => + index < deleted ? { id, outcome: 'deleted' } : { id, outcome: 'not_found', reasonCode: 'not_found' } + ), + }); + const noun = { experiments: 'experiment', flags: 'feature flag', segments: 'segment' }[config.entity]; + expect(notifications.showSuccess).toHaveBeenCalledWith( + deleted ? `1 ${noun} deleted. 1 item was already absent.` : '2 items were already absent.' + ); + expect(notifications.showSuccess).toHaveBeenCalledTimes(1); + expect(notifications.showWarning).not.toHaveBeenCalled(); + expect(notifications.showError).not.toHaveBeenCalled(); + expect(Object.keys(batch().selectedById)).toEqual([]); + expect(currentRows().map(({ id }) => id)).toEqual([rows[2].id]); + }); + + it('reports an error and retains selections when the first deletion fails', () => { + selectRows(); + const snapshot = prepare(); + store.dispatch(actions.batchDeleteRequested({ snapshot })); + response.next({ + phase: 'executed', + results: rows.map(({ id }, index) => ({ id, outcome: index === 0 ? 'failed' : 'not_attempted' })), + }); + expect(notifications.showError).toHaveBeenCalledWith('1 item could not be deleted. 2 items were not attempted.'); + expect(notifications.showError).toHaveBeenCalledTimes(1); + expect(notifications.showSuccess).not.toHaveBeenCalled(); + expect(notifications.showWarning).not.toHaveBeenCalled(); + expect(Object.keys(batch().selectedById)).toEqual(rows.map(({ id }) => id)); + expect(currentRows()).toEqual(rows); + expect(selectionView(batch(), config.entity).busy).toBe(false); + }); + it('freezes the request, rejects duplicate submits, and preserves failures while refreshing the current query', () => { selectRows(); const snapshot = prepare(); @@ -359,6 +399,8 @@ describe.each(fixtures)('$entity batch store/effects integration', (config) => { true ); expect(notifications.showWarning).toHaveBeenCalledTimes(1); + expect(notifications.showSuccess).not.toHaveBeenCalled(); + expect(notifications.showError).not.toHaveBeenCalled(); expect(router.navigate).not.toHaveBeenCalled(); }); @@ -489,6 +531,7 @@ describe.each(fixtures)('$entity batch store/effects integration', (config) => { expect(data[config.fetchMethod]).toHaveBeenCalledTimes(fetchCount); expect(notifications.showWarning).not.toHaveBeenCalled(); expect(notifications.showSuccess).not.toHaveBeenCalled(); + expect(notifications.showError).not.toHaveBeenCalled(); expect(Object.keys(batch().selectedById)).toHaveLength(3); expect(data.batchDelete).toHaveBeenCalledTimes(1); store.dispatch(actions.toggleRow({ item: selectionItem(rows[0]) })); @@ -582,6 +625,8 @@ describe.each(fixtures)('$entity batch store/effects integration', (config) => { store.dispatch(actions.toggleRow({ item: selectionItem(rows[1]) })); expect(batch().selectedById[rows[1].id]).toBeDefined(); expect(notifications.showWarning).toHaveBeenCalledTimes(1); + expect(notifications.showSuccess).not.toHaveBeenCalled(); + expect(notifications.showError).not.toHaveBeenCalled(); }); it('retains an unknown item after refreshing the list and reports the confirmed results once', () => { @@ -608,11 +653,12 @@ describe.each(fixtures)('$entity batch store/effects integration', (config) => { ); expect(notifications.showWarning).toHaveBeenCalledTimes(1); expect(notifications.showSuccess).not.toHaveBeenCalled(); + expect(notifications.showError).not.toHaveBeenCalled(); expect(data.batchDelete).toHaveBeenCalledTimes(1); expect(data[config.fetchMethod]).toHaveBeenCalledTimes(fetchCount + 1); }); - it('retains uncertain selections and warns once about a malformed deletion response', () => { + it('retains uncertain selections and reports an error once about a malformed deletion response', () => { selectRows(); const snapshot = prepare(); store.dispatch(actions.batchDeleteRequested({ snapshot })); @@ -623,7 +669,9 @@ describe.each(fixtures)('$entity batch store/effects integration', (config) => { expect(selectionView(batch(), config.entity).busy).toBe(false); expect(data.batchDelete).toHaveBeenCalledTimes(1); expect(notifications.showSuccess).not.toHaveBeenCalled(); - expect(notifications.showWarning).toHaveBeenCalledTimes(1); + expect(notifications.showWarning).not.toHaveBeenCalled(); + expect(notifications.showError).toHaveBeenCalledWith('Deletion could not be confirmed for some items.'); + expect(notifications.showError).toHaveBeenCalledTimes(1); }); it('requires a new confirmation and sends only retained IDs on an explicit retry', () => { 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 cdb84ff81..b951c7dbf 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 @@ -51,10 +51,10 @@ export class ExperimentEffects { experimentAction.batchActions, (state) => { const counts = batchResultCounts(state); - const hasWarnings = counts.hasErrors || counts.uncertain; const message = batchResultMessage('experiments', counts, (key, params) => this.translate.instant(key, params)); - if (hasWarnings) this.notificationService.showWarning(message); - else this.notificationService.showSuccess(message); + if (!counts.hasErrors) this.notificationService.showSuccess(message); + else if (counts.deleted || counts.absent) this.notificationService.showWarning(message); + else this.notificationService.showError(message); const pathname = (this.router.url || '').split('?')[0].split('#')[0]; return [ // Detail selectors share the rows array; replace it only while the root table is displayed. 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 bbe953535..0e62a0ab0 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 @@ -38,10 +38,10 @@ export class FeatureFlagsEffects { FeatureFlagsActions.batchActions, (state) => { const counts = batchResultCounts(state); - const hasWarnings = counts.hasErrors || counts.uncertain; const message = batchResultMessage('flags', counts, (key, params) => this.translate.instant(key, params)); - if (hasWarnings) this.notificationService.showWarning(message); - else this.notificationService.showSuccess(message); + if (!counts.hasErrors) this.notificationService.showSuccess(message); + else if (counts.deleted || counts.absent) this.notificationService.showWarning(message); + else this.notificationService.showError(message); return [FeatureFlagsActions.actionFetchFeatureFlags({ fromStarting: true, batchRefresh: 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 efe9ff12b..a105c8341 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 @@ -36,10 +36,10 @@ export class SegmentsEffects { SegmentsActions.batchActions, (state) => { const counts = batchResultCounts(state); - const hasWarnings = counts.hasErrors || counts.uncertain; const message = batchResultMessage('segments', counts, (key, params) => this.translate.instant(key, params)); - if (hasWarnings) this.notificationService.showWarning(message); - else this.notificationService.showSuccess(message); + if (!counts.hasErrors) this.notificationService.showSuccess(message); + else if (counts.deleted || counts.absent) this.notificationService.showWarning(message); + else this.notificationService.showError(message); const pathname = (this.router.url || '').split('?')[0].split('#')[0]; return [ // Detail selectors share the rows array; replace it only while the root table is displayed. diff --git a/packages/frontend/projects/upgrade/src/assets/i18n/en.json b/packages/frontend/projects/upgrade/src/assets/i18n/en.json index aadaac1a9..fe72c6f1c 100644 --- a/packages/frontend/projects/upgrade/src/assets/i18n/en.json +++ b/packages/frontend/projects/upgrade/src/assets/i18n/en.json @@ -28,8 +28,7 @@ "batch-delete.success.segments.other": "{{deleted}} segments deleted.", "batch-delete.result.absent.one": "1 item was already absent.", "batch-delete.result.absent.other": "{{count}} items were already absent.", - "batch-delete.result.failed.one": "1 item could not be deleted.", - "batch-delete.result.failed.other": "{{count}} items could not be deleted.", + "batch-delete.result.failed": "1 item could not be deleted.", "batch-delete.result.notAttempted.one": "1 item was not attempted.", "batch-delete.result.notAttempted.other": "{{count}} items were not attempted.", "batch-delete.result.post-delete-failed": "An error occurred after deletion.", From 7e2213d53d34f667681296d0e4a6afb78163b028 Mon Sep 17 00:00:00 2001 From: Zack Lee Date: Wed, 16 Sep 2026 17:59:39 -0400 Subject: [PATCH 36/61] test: trim redundant batch deletion cases and correct response fixtures --- .../batch-actions/batch-actions.http.spec.ts | 2 +- .../batch-actions.integration.spec.ts | 31 ++++++++++++------- 2 files changed, 20 insertions(+), 13 deletions(-) diff --git a/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.http.spec.ts b/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.http.spec.ts index be80fb864..1fbb2fcfc 100644 --- a/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.http.spec.ts +++ b/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.http.spec.ts @@ -56,7 +56,7 @@ describe('Batch HTTP contracts', () => { navigation.complete(); }); - it.each([0, 400, 403, 404, 500, 504])('reports HTTP %i using the existing error popup', (status) => { + it.each([0, 404])('reports HTTP %i using the existing error popup', (status) => { const notification = { create: jest.fn() }; const auth = { authLogout: jest.fn() }; const interceptor = new HttpErrorInterceptor(auth as any, notification as any, {} as any); diff --git a/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.integration.spec.ts b/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.integration.spec.ts index 2a83fa2da..0079fcf06 100644 --- a/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.integration.spec.ts +++ b/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.integration.spec.ts @@ -1,4 +1,4 @@ -import { TestBed, fakeAsync, tick } from '@angular/core/testing'; +import { TestBed } from '@angular/core/testing'; import { TranslateModule, TranslateService } from '@ngx-translate/core'; import { Actions, getEffectsMetadata } from '@ngrx/effects'; import { Action, ScannedActionsSubject, Store, StoreModule } from '@ngrx/store'; @@ -314,20 +314,29 @@ describe.each(fixtures)('$entity batch store/effects integration', (config) => { expect(data.batchDelete).toHaveBeenCalledWith(rows.map(({ id }) => id)); }); - it('retains cached selections until the delete response establishes an absence', () => { + it('removes an absent item and retains failed and unattempted selections with a warning', () => { selectRows(); const snapshot = prepare(); expect(snapshot.items).toHaveLength(3); store.dispatch(actions.batchDeleteRequested({ snapshot })); response.next({ - phase: 'rejected', - results: rows.map(({ id }, index) => ({ id, outcome: index === 1 ? 'not_found' : 'not_attempted' })), + phase: 'executed', + results: [ + { id: rows[0].id, outcome: 'not_found', reasonCode: 'not_found' }, + { id: rows[1].id, outcome: 'failed', reasonCode: 'delete_failed' }, + { id: rows[2].id, outcome: 'not_attempted' }, + ], }); - expect(batch().selectedById[rows[1].id]).toBeUndefined(); - expect(currentRows().some((row) => row.id === rows[1].id)).toBe(false); - expect(batchResultCounts(batch())).toMatchObject({ deleted: 0, absent: 1 }); + expect(Object.keys(batch().selectedById)).toEqual([rows[1].id, rows[2].id]); + expect(currentRows().map(({ id }) => id)).toEqual([rows[1].id, rows[2].id]); + expect(batchResultCounts(batch())).toMatchObject({ deleted: 0, absent: 1, failed: 1, notAttempted: 1 }); + expect(notifications.showWarning).toHaveBeenCalledWith( + '1 item was already absent. 1 item could not be deleted. 1 item was not attempted.' + ); expect(notifications.showWarning).toHaveBeenCalledTimes(1); + expect(notifications.showSuccess).not.toHaveBeenCalled(); + expect(notifications.showError).not.toHaveBeenCalled(); expect(data[config.fetchMethod]).toHaveBeenLastCalledWith(expect.objectContaining({ skip: 0 }), true); expect(batch().confirmation).toBeNull(); }); @@ -444,7 +453,7 @@ describe.each(fixtures)('$entity batch store/effects integration', (config) => { } ); - it('keeps the displayed confirmation snapshot when updated rows arrive or focus returns', fakeAsync(() => { + it('keeps the displayed confirmation snapshot when updated rows arrive', () => { selectRows(2); const snapshot = prepare(); data[config.fetchMethod].mockReturnValueOnce( @@ -460,15 +469,13 @@ describe.each(fixtures)('$entity batch store/effects integration', (config) => { ) ); store.dispatch(config.fetch({ fromStarting: true })); - window.dispatchEvent(new Event('focus')); - tick(100); expect(batch().confirmation).toBe(snapshot); expect(snapshot.items.map(({ name }) => name)).toEqual(['a', 'b']); expect(batch().selectedById[rows[0].id]).toEqual(selectionItem(rows[0])); store.dispatch(actions.batchDeleteRequested({ snapshot })); expect(data.batchDelete).toHaveBeenCalledWith([rows[0].id, rows[1].id]); - })); + }); it.each([1, 3])('reports %i deletions once and ignores stale reads started before or during deletion', (count) => { const options = rows.map(({ id, name }) => ({ id, name, context: 'home' })); @@ -504,7 +511,7 @@ describe.each(fixtures)('$entity batch store/effects integration', (config) => { expect(notifications.showSuccess).toHaveBeenCalledWith(`${count} ${noun}${count === 1 ? '' : 's'} deleted.`); }); - it.each([0, 400, 403, 500, 504])( + it.each([0, 500])( 'restores cancelled replacement rows after HTTP %i without another request or snackbar', (status) => { selectRows(); From 8afb8df637ce53d54665f8b3e83ad0cdb8eacd33 Mon Sep 17 00:00:00 2001 From: Zack Lee Date: Wed, 16 Sep 2026 18:25:11 -0400 Subject: [PATCH 37/61] fix: refresh segments when filtering by tag --- .../batch-actions.integration.spec.ts | 24 +++++++++++++++++++ ...gment-root-section-card-table.component.ts | 1 + 2 files changed, 25 insertions(+) diff --git a/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.integration.spec.ts b/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.integration.spec.ts index 0079fcf06..743c1a598 100644 --- a/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.integration.spec.ts +++ b/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.integration.spec.ts @@ -7,6 +7,7 @@ import { EXPERIMENT_STATE, FEATURE_FLAG_STATUS, FLAG_SEARCH_KEY, + SEGMENT_SEARCH_KEY, SEGMENT_STATUS, SORT_AS_DIRECTION, UserRole, @@ -24,6 +25,8 @@ import { FeatureFlagsEffects } from '../feature-flags/store/feature-flags.effect import { FeatureFlagsService } from '../feature-flags/feature-flags.service'; import { FeatureFlagRootSectionCardComponent } from '../../features/dashboard/feature-flags/pages/feature-flag-root-page/feature-flag-root-page-content/feature-flag-root-section-card/feature-flag-root-section-card.component'; import { SegmentsEffects } from '../segments/store/segments.effects'; +import { SegmentsService } from '../segments/segments.service'; +import { SegmentRootSectionCardTableComponent } from '../../features/dashboard/segments/pages/segment-root-page/segment-root-page-content/segment-root-section-card/segment-root-section-card-table/segment-root-section-card-table.component'; import { actionLogoutStart, actionSetUserInfo } from '../auth/store/auth.actions'; import { batchResultCounts, selectionItem, selectionView } from './batch-actions.helpers'; import { RootBatchState, newBatchRequestId } from './batch-actions.models'; @@ -202,6 +205,27 @@ describe.each(fixtures)('$entity batch store/effects integration', (config) => { }); } + if (config.entity === 'segments') { + it('fetches filtered rows when a tag is clicked and keeps the returned rows selectable', () => { + const segmentsService = new SegmentsService(store as any, data, { setItem: jest.fn() } as any); + const table = new SegmentRootSectionCardTableComponent(segmentsService); + data[config.fetchMethod].mockReturnValueOnce(of(page([rows[0]]))); + + table.filterSegmentByChips('tag', SEGMENT_SEARCH_KEY.TAG); + + expect(data[config.fetchMethod]).toHaveBeenCalledTimes(2); + expect(data[config.fetchMethod]).toHaveBeenLastCalledWith( + expect.objectContaining({ skip: 0, searchParams: { key: SEGMENT_SEARCH_KEY.TAG, string: 'tag' } }), + false + ); + expect(currentRows().map(({ id }) => id)).toEqual([rows[0].id]); + expect(batch().loadedIds).toEqual([rows[0].id]); + expect(state.segments.isLoadingSegments).toBe(false); + store.dispatch(actions.toggleRow({ item: selectionItem(rows[0]) })); + expect(Object.keys(batch().selectedById)).toEqual([rows[0].id]); + }); + } + it('selects loaded rows, retains hidden selections across replacement reads, and clears all from the header', () => { expect(batch().loadedIds).toHaveLength(3); // Also exercises NgRx's queued list-start dispatch. selectRows(2); diff --git a/packages/frontend/projects/upgrade/src/app/features/dashboard/segments/pages/segment-root-page/segment-root-page-content/segment-root-section-card/segment-root-section-card-table/segment-root-section-card-table.component.ts b/packages/frontend/projects/upgrade/src/app/features/dashboard/segments/pages/segment-root-page/segment-root-page-content/segment-root-section-card/segment-root-section-card-table/segment-root-section-card-table.component.ts index c9d20c37f..20ade4507 100644 --- a/packages/frontend/projects/upgrade/src/app/features/dashboard/segments/pages/segment-root-page/segment-root-page-content/segment-root-section-card/segment-root-section-card-table/segment-root-section-card-table.component.ts +++ b/packages/frontend/projects/upgrade/src/app/features/dashboard/segments/pages/segment-root-page/segment-root-page-content/segment-root-section-card/segment-root-section-card-table/segment-root-section-card-table.component.ts @@ -93,6 +93,7 @@ export class SegmentRootSectionCardTableComponent implements AfterViewInit, OnDe filterSegmentByChips(tagValue: string, type: SEGMENT_SEARCH_KEY) { this.setSearchKey(type); this.setSearchString(tagValue); + this.segmentsService.fetchSegmentsPaginated(true); } setSearchKey(searchKey: SEGMENT_SEARCH_KEY) { From 6e1bc9243ee7a116f3a14ca5fe7ecfcea72f8da3 Mon Sep 17 00:00:00 2001 From: Zack Lee Date: Wed, 16 Sep 2026 22:48:37 -0400 Subject: [PATCH 38/61] fix: await segment recomputation between batch deletions --- .../src/api/services/BatchDeleteService.ts | 2 +- .../src/api/services/SegmentService.ts | 36 ++++++++--- .../test/integration/BatchDelete/index.ts | 64 +++++++++++++++++-- .../unit/services/BatchDeleteService.test.ts | 48 +++++++++++++- 4 files changed, 131 insertions(+), 19 deletions(-) diff --git a/packages/backend/src/api/services/BatchDeleteService.ts b/packages/backend/src/api/services/BatchDeleteService.ts index f4c64f7d8..59ff7e34a 100644 --- a/packages/backend/src/api/services/BatchDeleteService.ts +++ b/packages/backend/src/api/services/BatchDeleteService.ts @@ -163,7 +163,7 @@ export class BatchDeleteService { } else if (entity === 'flags') { await this.flags.delete(id, user, logger, executeTransaction); } else { - await this.segments.deleteSegment(id, logger, executeTransaction); + await this.segments.deleteSegment(id, logger, executeTransaction, true); } return committed ? { id, outcome: 'deleted' } diff --git a/packages/backend/src/api/services/SegmentService.ts b/packages/backend/src/api/services/SegmentService.ts index 4f00c26e9..ffe219a3e 100644 --- a/packages/backend/src/api/services/SegmentService.ts +++ b/packages/backend/src/api/services/SegmentService.ts @@ -539,25 +539,41 @@ export class SegmentService { public async deleteSegment( id: string, logger: UpgradeLogger, - executeTransaction?: DeletionTransaction + executeTransaction?: DeletionTransaction, + waitForRecompute = false ): Promise { logger.info({ message: `Delete segment by id. segmentId: ${id}` }); - // Both flags and experiments can reference this segment, so both precomputed tables must be - // refreshed. The affected experiment IDs must be collected BEFORE the delete (the join rows are - // gone after), so resolve them here; the fire-and-forget recompute is fired after the delete - // transaction below commits. Flags use withRecompute, which enforces the same - // resolve-before -> delete -> recompute-after ordering internally. + // Resolve affected owners before deleting their join rows. Single deletions keep background + // recomputation; batches wait below so consecutive items cannot leave overlapping owner writes. const affectedExperimentIds = await this.experimentPrecomputedSegmentService.getAffectedExperimentIds(id); const transaction: DeletionTransaction = executeTransaction || ((work) => this.dataSource.transaction(work)); + const work = () => + transaction((transactionalEntityManager) => + this.deleteSegmentAndPrivateSubsegments(id, logger, transactionalEntityManager) + ); + + if (waitForRecompute) { + // A batch must finish this item's post-commit writes before deleting another segment for the same owner. + const affectedFlagIds = await this.featureFlagPrecomputedSegmentService.getAffectedFlagIds(id); + const deletedSegment = await work(); + const updates = await Promise.allSettled([ + ...affectedFlagIds.map((flagId) => this.featureFlagPrecomputedSegmentService.recomputeForFlag(flagId, logger)), + ...affectedExperimentIds.map((experimentId) => + this.experimentPrecomputedSegmentService.recomputeForExperiment(experimentId, logger) + ), + this.cacheService.resetPrefixCache(CACHE_PREFIX.SEGMENT_KEY_PREFIX), + this.cacheService.resetPrefixCache(CACHE_PREFIX.GLOBAL_EXCLUDE_SEGMENT_KEY_PREFIX), + ]); + const failure = updates.find((update): update is PromiseRejectedResult => update.status === 'rejected'); + if (failure) throw failure.reason; + return deletedSegment; + } const deletedSegment = await this.featureFlagPrecomputedSegmentService.withRecompute( logger, () => this.featureFlagPrecomputedSegmentService.getAffectedFlagIds(id), - () => - transaction((transactionalEntityManager) => - this.deleteSegmentAndPrivateSubsegments(id, logger, transactionalEntityManager) - ) + work ); this.experimentPrecomputedSegmentService.scheduleRecomputeForExperiments(affectedExperimentIds, logger); diff --git a/packages/backend/test/integration/BatchDelete/index.ts b/packages/backend/test/integration/BatchDelete/index.ts index c1f8474aa..e48eae654 100644 --- a/packages/backend/test/integration/BatchDelete/index.ts +++ b/packages/backend/test/integration/BatchDelete/index.ts @@ -26,8 +26,10 @@ import { ErrorHandlerMiddleware } from '../../../src/api/middlewares/ErrorHandle import { LogMiddleware } from '../../../src/api/middlewares/LogMiddleware'; import { Experiment } from '../../../src/api/models/Experiment'; import { ExperimentAuditLog } from '../../../src/api/models/ExperimentAuditLog'; +import { ExperimentPrecomputedSegment } from '../../../src/api/models/ExperimentPrecomputedSegment'; import { ExperimentSegmentInclusion } from '../../../src/api/models/ExperimentSegmentInclusion'; import { FeatureFlag } from '../../../src/api/models/FeatureFlag'; +import { FeatureFlagPrecomputedSegment } from '../../../src/api/models/FeatureFlagPrecomputedSegment'; import { FeatureFlagSegmentInclusion } from '../../../src/api/models/FeatureFlagSegmentInclusion'; import { IndividualForSegment } from '../../../src/api/models/IndividualForSegment'; import { MoocletExperimentRef } from '../../../src/api/models/MoocletExperimentRef'; @@ -41,6 +43,7 @@ import { SegmentService } from '../../../src/api/services/SegmentService'; import { BatchDeleteService } from '../../../src/api/services/BatchDeleteService'; import { currentUserChecker } from '../../../src/auth/currentUserChecker'; import { env } from '../../../src/env'; +import { UpgradeLogger } from '../../../src/lib/logger/UpgradeLogger'; import { iocLoader } from '../../../src/loaders/iocLoader'; const entities: BatchDeleteEntity[] = ['experiments', 'flags', 'segments']; @@ -307,6 +310,60 @@ export function registerBatchDeleteTests(connections: () => [DataSource, DataSou } }); + test.each([false, true])('updates shared owners after segment deletion (recompute fails: %s)', async (fails) => { + const rows = await create('segments', 2); + const [flag] = await create('flags'); + const [experiment] = await create('experiments'); + const members = rows.map((row) => `member-${row.id}`); + await db + .getRepository(IndividualForSegment) + .insert(rows.map((row, index) => ({ segmentId: row.id, userId: members[index] }))); + await db.getRepository(FeatureFlagSegmentInclusion).insert( + rows.map((row) => ({ + featureFlagId: flag.id, + segmentId: row.id, + enabled: true, + listType: STANDARD_LIST_TYPE.SEGMENT, + })) + ); + await db + .getRepository(ExperimentSegmentInclusion) + .insert(rows.map((row) => ({ experimentId: experiment.id, segmentId: row.id }))); + const flags = Container.get(FeatureFlagPrecomputedSegmentService); + const experiments = Container.get(ExperimentPrecomputedSegmentService); + const logger = new UpgradeLogger(); + await flags.recomputeForFlag(flag.id, logger); + await experiments.recomputeForExperiment(experiment.id, logger); + if (fails) jest.spyOn(flags, 'recomputeForFlag').mockRejectedValueOnce(new Error('Recompute failed')); + + const { body } = await request(app) + .post(route('segments')) + .send({ ids: rows.map((row) => row.id) }) + .expect(200); + if (fails) { + expect(body.results).toEqual([ + { id: rows[0].id, outcome: 'deleted', reasonCode: DeletionReasonCode.POST_DELETE_FAILED }, + { id: rows[1].id, outcome: 'not_attempted' }, + ]); + expect(await db.getRepository(Segment).countBy({ id: rows[0].id })).toBe(0); + expect(await db.getRepository(Segment).countBy({ id: rows[1].id })).toBe(1); + } else { + expect(body.results).toEqual(rows.map((row) => ({ id: row.id, outcome: 'deleted' }))); + expect( + await db.getRepository(FeatureFlagPrecomputedSegment).findOneBy({ featureFlagId: flag.id }) + ).toMatchObject({ + inclusionIds: [], + exclusionIds: [], + }); + expect( + await db.getRepository(ExperimentPrecomputedSegment).findOneBy({ experimentId: experiment.id }) + ).toMatchObject({ + inclusionIds: [], + exclusionIds: [], + }); + } + }); + test('keeps a committed segment deletion when post-commit cache invalidation fails', async () => { const rows = await create('segments', 2); const cache = Container.get(CacheService); @@ -319,11 +376,6 @@ export function registerBatchDeleteTests(connections: () => [DataSource, DataSou } return reset(prefix); }); - const flags = jest.spyOn(Container.get(FeatureFlagPrecomputedSegmentService), 'withRecompute'); - const experiments = jest.spyOn( - Container.get(ExperimentPrecomputedSegmentService), - 'scheduleRecomputeForExperiments' - ); const { body } = await request(app) .post(route('segments')) .send({ ids: rows.map((row) => row.id) }) @@ -333,8 +385,6 @@ export function registerBatchDeleteTests(connections: () => [DataSource, DataSou { id: rows[1].id, outcome: 'not_attempted' }, ]); expect(observedCount).toBe(0); - expect(flags).toHaveBeenCalledTimes(1); - expect(experiments).toHaveBeenCalledTimes(1); expect(await db.getRepository(Segment).countBy({ id: rows[1].id })).toBe(1); }); diff --git a/packages/backend/test/unit/services/BatchDeleteService.test.ts b/packages/backend/test/unit/services/BatchDeleteService.test.ts index 92811c7ae..20c6ec763 100644 --- a/packages/backend/test/unit/services/BatchDeleteService.test.ts +++ b/packages/backend/test/unit/services/BatchDeleteService.test.ts @@ -6,6 +6,8 @@ import { BatchDeleteService } from '../../../src/api/services/BatchDeleteService import { ExperimentService } from '../../../src/api/services/ExperimentService'; import { FeatureFlagService } from '../../../src/api/services/FeatureFlagService'; import { SegmentService } from '../../../src/api/services/SegmentService'; +import { FeatureFlagPrecomputedSegmentService } from '../../../src/api/services/FeatureFlagPrecomputedSegmentService'; +import { ExperimentPrecomputedSegmentService } from '../../../src/api/services/ExperimentPrecomputedSegmentService'; import { MoocletExperimentService } from '../../../src/api/services/MoocletExperimentService'; import { MoocletError } from '../../../src/api/errors/MoocletError'; import { UpgradeLogger } from '../../../src/lib/logger/UpgradeLogger'; @@ -17,7 +19,7 @@ jest.mock('perf_hooks', () => ({ performance: { now: jest.fn(() => 0) } })); describe('BatchDeleteService transaction outcomes', () => { const entities: BatchDeleteEntity[] = ['experiments', 'flags', 'segments']; const user = { email: 'batch@example.com', firstName: 'Batch', lastName: 'User', role: UserRole.ADMIN }; - const logger = { error: jest.fn() } as unknown as UpgradeLogger; + const logger = { error: jest.fn(), info: jest.fn() } as unknown as UpgradeLogger; let ids: string[]; let service: BatchDeleteService; let runners: QueryRunner[]; @@ -118,6 +120,50 @@ describe('BatchDeleteService transaction outcomes', () => { } }); + test('finishes both owner recomputations before deleting the next segment', async () => { + const selectedIds = ids.slice(0, 2); + const saved = { flags: [] as string[], experiments: [] as string[] }; + let releaseFirstWrites: () => void; + const firstWrites = new Promise((resolve) => (releaseFirstWrites = resolve)); + const recompute = (entity: keyof typeof saved) => async () => { + const members = selectedIds.filter((id) => !mutations.includes(id)); + if (members.length) await firstWrites; + saved[entity] = members; + }; + // Use the real deletion/scheduling methods, holding the first post-delete writes to expose overlap. + const segmentService = Object.assign(Object.create(SegmentService.prototype), { + featureFlagPrecomputedSegmentService: Object.assign( + Object.create(FeatureFlagPrecomputedSegmentService.prototype), + { + getAffectedFlagIds: async () => ['flag'], + recomputeOwner: recompute('flags'), + } + ), + experimentPrecomputedSegmentService: Object.assign(Object.create(ExperimentPrecomputedSegmentService.prototype), { + getAffectedExperimentIds: async () => ['experiment'], + recomputeOwner: recompute('experiments'), + }), + cacheService: { resetPrefixCache: jest.fn().mockResolvedValue(undefined) }, + deleteSegmentAndPrivateSubsegments: async (id) => (await work(id))[0], + }); + segments.deleteSegment.mockImplementation(segmentService.deleteSegment.bind(segmentService)); + let completed = false; + const deletion = service.delete('segments', selectedIds, user, logger).then((result) => { + completed = true; + return result; + }); + try { + await new Promise((resolve) => setImmediate(resolve)); + expect(mutations).toEqual([selectedIds[0]]); + expect(completed).toBe(false); + } finally { + releaseFirstWrites(); + await deletion; + } + expect(saved).toEqual({ flags: [], experiments: [] }); + expect((await deletion).results.map((item) => item.outcome)).toEqual(['deleted', 'deleted']); + }); + test('stops without mutation when the target lookup fails', async () => { configureRunner = (runner) => { (runner.manager.getRepository as jest.Mock).mockReturnValue({ From 6e2802fb6f96762d44db3f8101898349e1679686 Mon Sep 17 00:00:00 2001 From: Zack Lee Date: Wed, 16 Sep 2026 23:37:02 -0400 Subject: [PATCH 39/61] fix: stop batch deletion when releasing a skipped item fails --- .../src/api/services/BatchDeleteService.ts | 4 +-- .../unit/services/BatchDeleteService.test.ts | 27 ++++++++++--------- 2 files changed, 17 insertions(+), 14 deletions(-) diff --git a/packages/backend/src/api/services/BatchDeleteService.ts b/packages/backend/src/api/services/BatchDeleteService.ts index 59ff7e34a..fa9559dd1 100644 --- a/packages/backend/src/api/services/BatchDeleteService.ts +++ b/packages/backend/src/api/services/BatchDeleteService.ts @@ -142,8 +142,8 @@ export class BatchDeleteService { await runner.release(); } catch (releaseError) { logger.error({ message: 'Batch deletion connection release failed', id, releaseError }); - // Preserve the transaction failure if connection cleanup also fails. - if (!transactionError) transactionError = releaseError; + // Preserve an execution failure, but do not let a normal skip hide a release failure. + if (!transactionError || transactionError instanceof BatchDeleteSkippedError) transactionError = releaseError; } } if (transactionError) throw transactionError; diff --git a/packages/backend/test/unit/services/BatchDeleteService.test.ts b/packages/backend/test/unit/services/BatchDeleteService.test.ts index 20c6ec763..b57b0a198 100644 --- a/packages/backend/test/unit/services/BatchDeleteService.test.ts +++ b/packages/backend/test/unit/services/BatchDeleteService.test.ts @@ -189,18 +189,21 @@ describe('BatchDeleteService transaction outcomes', () => { expect(runners.every((runner) => (runner.rollbackTransaction as jest.Mock).mock.calls.length === 1)).toBe(true); }); - test('stops when rollback fails even if the target was missing before mutation', async () => { - configureRunner = (runner) => { - (runner.manager.getRepository as jest.Mock).mockReturnValue({ - findOne: jest.fn().mockResolvedValue(null), - }); - (runner.rollbackTransaction as jest.Mock).mockRejectedValue(new Error('connection lost')); - }; - const result = await service.delete('flags', ids, user, logger); - expect(result.results.map((item) => item.outcome)).toEqual(['failed', 'not_attempted', 'not_attempted']); - expect(mutations).toEqual([]); - expect(createQueryRunner).toHaveBeenCalledTimes(1); - }); + test.each(['rollbackTransaction', 'release'] as const)( + 'stops when %s fails even if the target was missing before mutation', + async (method) => { + configureRunner = (runner) => { + (runner.manager.getRepository as jest.Mock).mockReturnValue({ + findOne: jest.fn().mockResolvedValue(null), + }); + (runner[method] as jest.Mock).mockRejectedValue(new Error('connection lost')); + }; + const result = await service.delete('flags', ids, user, logger); + expect(result.results.map((item) => item.outcome)).toEqual(['failed', 'not_attempted', 'not_attempted']); + expect(mutations).toEqual([]); + expect(createQueryRunner).toHaveBeenCalledTimes(1); + } + ); test.each([0, 1])('stops after item %i fails and preserves earlier commits', async (index) => { work.mockImplementation(async (id) => { From e335cd8ceb057b372af80cf9a59e2e1e92e7cee5 Mon Sep 17 00:00:00 2001 From: Zack Lee Date: Thu, 17 Sep 2026 01:01:07 -0400 Subject: [PATCH 40/61] fix: avoid error logging for expected batch deletion skips --- packages/backend/src/api/services/BatchDeleteService.ts | 2 +- .../backend/test/unit/services/BatchDeleteService.test.ts | 4 ++++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/backend/src/api/services/BatchDeleteService.ts b/packages/backend/src/api/services/BatchDeleteService.ts index fa9559dd1..e68b6a562 100644 --- a/packages/backend/src/api/services/BatchDeleteService.ts +++ b/packages/backend/src/api/services/BatchDeleteService.ts @@ -169,13 +169,13 @@ export class BatchDeleteService { ? { id, outcome: 'deleted' } : { id, outcome: 'unknown', reasonCode: DeletionReasonCode.OUTCOME_UNKNOWN }; } catch (error) { + if (error instanceof BatchDeleteSkippedError && rolledBack) return error.result; logger.error({ message: 'Batch deletion item failed', entity, id, committed, error }); if (committed) return { id, outcome: 'deleted', reasonCode: DeletionReasonCode.POST_DELETE_FAILED }; // A failed COMMIT can mean the server committed but its acknowledgment was lost. if (commitAttempted || (mutationStarted && !rolledBack)) { return { id, outcome: 'unknown', reasonCode: DeletionReasonCode.OUTCOME_UNKNOWN }; } - if (error instanceof BatchDeleteSkippedError && rolledBack) return error.result; return { id, outcome: 'failed', diff --git a/packages/backend/test/unit/services/BatchDeleteService.test.ts b/packages/backend/test/unit/services/BatchDeleteService.test.ts index b57b0a198..13b76b49c 100644 --- a/packages/backend/test/unit/services/BatchDeleteService.test.ts +++ b/packages/backend/test/unit/services/BatchDeleteService.test.ts @@ -34,6 +34,7 @@ describe('BatchDeleteService transaction outcomes', () => { let configureRunner: (runner: QueryRunner, index: number) => void; beforeEach(() => { + (logger.error as jest.Mock).mockClear(); ids = [randomUUID(), randomUUID(), randomUUID()]; mutations = []; runners = []; @@ -187,6 +188,7 @@ describe('BatchDeleteService transaction outcomes', () => { }); expect(mutations).toEqual([]); expect(runners.every((runner) => (runner.rollbackTransaction as jest.Mock).mock.calls.length === 1)).toBe(true); + expect(logger.error).not.toHaveBeenCalled(); }); test.each(['rollbackTransaction', 'release'] as const)( @@ -202,6 +204,7 @@ describe('BatchDeleteService transaction outcomes', () => { expect(result.results.map((item) => item.outcome)).toEqual(['failed', 'not_attempted', 'not_attempted']); expect(mutations).toEqual([]); expect(createQueryRunner).toHaveBeenCalledTimes(1); + expect(logger.error).toHaveBeenCalledWith(expect.objectContaining({ message: 'Batch deletion item failed' })); } ); @@ -367,5 +370,6 @@ describe('BatchDeleteService transaction outcomes', () => { }); expect(mutations).toEqual([]); expect(runners[0].rollbackTransaction).toHaveBeenCalled(); + expect(logger.error).not.toHaveBeenCalled(); }); }); From 001d02d59e0631240ca7b6690bc71dc7b333a20a Mon Sep 17 00:00:00 2001 From: Zack Lee Date: Thu, 17 Sep 2026 01:35:51 -0400 Subject: [PATCH 41/61] fix: propagate batch budget reason to remaining items --- .../backend/src/api/services/BatchDeleteService.ts | 3 +++ .../test/unit/services/BatchDeleteService.test.ts | 10 ++++++---- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/packages/backend/src/api/services/BatchDeleteService.ts b/packages/backend/src/api/services/BatchDeleteService.ts index e68b6a562..89c2ce438 100644 --- a/packages/backend/src/api/services/BatchDeleteService.ts +++ b/packages/backend/src/api/services/BatchDeleteService.ts @@ -78,6 +78,9 @@ export class BatchDeleteService { (remaining): BatchDeleteItemResult => ({ id: remaining, outcome: 'not_attempted', + ...(result.reasonCode === DeletionReasonCode.BATCH_BUDGET_EXCEEDED + ? { reasonCode: DeletionReasonCode.BATCH_BUDGET_EXCEEDED } + : {}), }) ) ); diff --git a/packages/backend/test/unit/services/BatchDeleteService.test.ts b/packages/backend/test/unit/services/BatchDeleteService.test.ts index 13b76b49c..4d89caa60 100644 --- a/packages/backend/test/unit/services/BatchDeleteService.test.ts +++ b/packages/backend/test/unit/services/BatchDeleteService.test.ts @@ -218,6 +218,9 @@ describe('BatchDeleteService transaction outcomes', () => { expect(result.results.map((item) => item.outcome)).toEqual( index === 0 ? ['failed', 'not_attempted', 'not_attempted'] : ['deleted', 'failed', 'not_attempted'] ); + expect(result.results.slice(index + 1)).toEqual( + ids.slice(index + 1).map((id) => ({ id, outcome: 'not_attempted' })) + ); expect(runners[index].rollbackTransaction).toHaveBeenCalledTimes(1); expect(runners[index].commitTransaction).not.toHaveBeenCalled(); expect(mutations).toEqual(ids.slice(0, index)); @@ -364,10 +367,9 @@ describe('BatchDeleteService transaction outcomes', () => { }); }; const result = await service.delete('flags', ids, user, logger); - expect(result.results[0]).toMatchObject({ - outcome: 'not_attempted', - reasonCode: DeletionReasonCode.BATCH_BUDGET_EXCEEDED, - }); + expect(result.results).toEqual( + ids.map((id) => ({ id, outcome: 'not_attempted', reasonCode: DeletionReasonCode.BATCH_BUDGET_EXCEEDED })) + ); expect(mutations).toEqual([]); expect(runners[0].rollbackTransaction).toHaveBeenCalled(); expect(logger.error).not.toHaveBeenCalled(); From d0dbcc99783cf5ce4b9cc1cb87ea5ea004303099 Mon Sep 17 00:00:00 2001 From: Zack Lee Date: Thu, 17 Sep 2026 02:02:02 -0400 Subject: [PATCH 42/61] fix: capture affected segment owners after acquiring deletion lock --- .../src/api/services/SegmentService.ts | 24 +++++++------ .../test/integration/BatchDelete/index.ts | 36 +++++++++++++++++++ 2 files changed, 50 insertions(+), 10 deletions(-) diff --git a/packages/backend/src/api/services/SegmentService.ts b/packages/backend/src/api/services/SegmentService.ts index ffe219a3e..baa7e0ef1 100644 --- a/packages/backend/src/api/services/SegmentService.ts +++ b/packages/backend/src/api/services/SegmentService.ts @@ -544,19 +544,19 @@ export class SegmentService { ): Promise { logger.info({ message: `Delete segment by id. segmentId: ${id}` }); - // Resolve affected owners before deleting their join rows. Single deletions keep background - // recomputation; batches wait below so consecutive items cannot leave overlapping owner writes. - const affectedExperimentIds = await this.experimentPrecomputedSegmentService.getAffectedExperimentIds(id); const transaction: DeletionTransaction = executeTransaction || ((work) => this.dataSource.transaction(work)); - const work = () => - transaction((transactionalEntityManager) => - this.deleteSegmentAndPrivateSubsegments(id, logger, transactionalEntityManager) - ); if (waitForRecompute) { + let affectedFlagIds: string[]; + let affectedExperimentIds: string[]; + const deletedSegment = await transaction(async (transactionalEntityManager) => { + // The batch executor holds the target lock before invoking this callback. + // Collect owners before deletion removes the joins, including edits committed while waiting for the lock. + affectedFlagIds = await this.featureFlagPrecomputedSegmentService.getAffectedFlagIds(id); + affectedExperimentIds = await this.experimentPrecomputedSegmentService.getAffectedExperimentIds(id); + return this.deleteSegmentAndPrivateSubsegments(id, logger, transactionalEntityManager); + }); // A batch must finish this item's post-commit writes before deleting another segment for the same owner. - const affectedFlagIds = await this.featureFlagPrecomputedSegmentService.getAffectedFlagIds(id); - const deletedSegment = await work(); const updates = await Promise.allSettled([ ...affectedFlagIds.map((flagId) => this.featureFlagPrecomputedSegmentService.recomputeForFlag(flagId, logger)), ...affectedExperimentIds.map((experimentId) => @@ -570,10 +570,14 @@ export class SegmentService { return deletedSegment; } + const affectedExperimentIds = await this.experimentPrecomputedSegmentService.getAffectedExperimentIds(id); const deletedSegment = await this.featureFlagPrecomputedSegmentService.withRecompute( logger, () => this.featureFlagPrecomputedSegmentService.getAffectedFlagIds(id), - work + () => + transaction((transactionalEntityManager) => + this.deleteSegmentAndPrivateSubsegments(id, logger, transactionalEntityManager) + ) ); this.experimentPrecomputedSegmentService.scheduleRecomputeForExperiments(affectedExperimentIds, logger); diff --git a/packages/backend/test/integration/BatchDelete/index.ts b/packages/backend/test/integration/BatchDelete/index.ts index e48eae654..bffb5b9ee 100644 --- a/packages/backend/test/integration/BatchDelete/index.ts +++ b/packages/backend/test/integration/BatchDelete/index.ts @@ -35,6 +35,7 @@ import { IndividualForSegment } from '../../../src/api/models/IndividualForSegme import { MoocletExperimentRef } from '../../../src/api/models/MoocletExperimentRef'; import { Segment } from '../../../src/api/models/Segment'; import { User } from '../../../src/api/models/User'; +import { DeletionRepository } from '../../../src/api/repositories/DeletionRepository'; import { CacheService } from '../../../src/api/services/CacheService'; import { ExperimentPrecomputedSegmentService } from '../../../src/api/services/ExperimentPrecomputedSegmentService'; import { FeatureFlagPrecomputedSegmentService } from '../../../src/api/services/FeatureFlagPrecomputedSegmentService'; @@ -364,6 +365,41 @@ export function registerBatchDeleteTests(connections: () => [DataSource, DataSou } }); + test('recomputes owners attached before the segment deletion lock is acquired', async () => { + const [segment] = await create('segments'); + const [flag] = await create('flags'); + const [experiment] = await create('experiments'); + await db.getRepository(IndividualForSegment).insert({ segmentId: segment.id, userId: 'deleted-member' }); + const flags = Container.get(FeatureFlagPrecomputedSegmentService); + const experiments = Container.get(ExperimentPrecomputedSegmentService); + const logger = new UpgradeLogger(); + const findForDeletion = DeletionRepository.prototype.findForDeletion; + jest + .spyOn(DeletionRepository.prototype, 'findForDeletion') + .mockImplementationOnce(async function (this: DeletionRepository, ...args) { + // Commit a list edit on another connection just before the deletion acquires its target lock. + const flagList = await ownedList('flags', flag.id, segment.id, writer); + await writer.getRepository(FeatureFlagSegmentInclusion).update({ segmentId: flagList.id }, { enabled: true }); + await ownedList('experiments', experiment.id, segment.id, writer); + await flags.recomputeForFlag(flag.id, logger); + await experiments.recomputeForExperiment(experiment.id, logger); + return findForDeletion.apply(this, args); + }); + + const { body } = await request(app) + .post(route('segments')) + .send({ ids: [segment.id] }) + .expect(200); + expect(body.results).toEqual([{ id: segment.id, outcome: 'deleted' }]); + expect(await db.getRepository(Segment).countBy({ id: segment.id })).toBe(0); + expect(await db.getRepository(FeatureFlagPrecomputedSegment).findOneBy({ featureFlagId: flag.id })).toMatchObject( + { inclusionIds: ['batch-member'], exclusionIds: [] } + ); + expect( + await db.getRepository(ExperimentPrecomputedSegment).findOneBy({ experimentId: experiment.id }) + ).toMatchObject({ inclusionIds: ['batch-member'], exclusionIds: [] }); + }); + test('keeps a committed segment deletion when post-commit cache invalidation fails', async () => { const rows = await create('segments', 2); const cache = Container.get(CacheService); From 55c5f8a47f3f2f0378e2f2da1bb7cc53f5e130ae Mon Sep 17 00:00:00 2001 From: Zack Lee Date: Thu, 17 Sep 2026 03:01:17 -0400 Subject: [PATCH 43/61] test: remove redundant batch HTTP error interceptor coverage --- .../batch-actions/batch-actions.http.spec.ts | 32 ++----------------- 1 file changed, 2 insertions(+), 30 deletions(-) diff --git a/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.http.spec.ts b/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.http.spec.ts index 1fbb2fcfc..952303718 100644 --- a/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.http.spec.ts +++ b/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.http.spec.ts @@ -1,13 +1,12 @@ import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing'; -import { HttpErrorResponse, HttpRequest } from '@angular/common/http'; +import { HttpRequest } from '@angular/common/http'; import { TestBed } from '@angular/core/testing'; import { ActivationEnd } from '@angular/router'; -import { Subject, throwError } from 'rxjs'; +import { Subject } from 'rxjs'; import { ExperimentDataService } from '../experiments/experiments.data.service'; import { FeatureFlagsDataService } from '../feature-flags/feature-flags.data.service'; import { SegmentsDataService } from '../segments/segments.data.service'; import { HttpCancelInterceptor, SKIP_NAVIGATION_CANCEL } from '../http-interceptors/http-cancel.interceptor'; -import { HttpErrorInterceptor } from '../http-interceptors/http-error.interceptor'; import { batchHttpContext } from './batch-actions.http'; describe('Batch HTTP contracts', () => { @@ -55,31 +54,4 @@ describe('Batch HTTP contracts', () => { subscription.unsubscribe(); navigation.complete(); }); - - it.each([0, 404])('reports HTTP %i using the existing error popup', (status) => { - const notification = { create: jest.fn() }; - const auth = { authLogout: jest.fn() }; - const interceptor = new HttpErrorInterceptor(auth as any, notification as any, {} as any); - const error = new HttpErrorResponse({ status }); - const failed = jest.fn(); - interceptor - .intercept(new HttpRequest('POST', '/flags/batch-delete', {}, { context: batchHttpContext() }), { - handle: () => throwError(() => error), - }) - .subscribe({ error: failed }); - expect(notification.create).toHaveBeenCalledTimes(1); - expect(notification.create.mock.calls[0][0]).toBe('Network call failed. See console for details.'); - expect(failed).toHaveBeenCalledWith(error); - }); - - it('preserves automatic logout for a batch 401', () => { - const auth = { authLogout: jest.fn() }; - const interceptor = new HttpErrorInterceptor(auth as any, { create: jest.fn() } as any, {} as any); - interceptor - .intercept(new HttpRequest('POST', '/flags/batch-delete', {}, { context: batchHttpContext() }), { - handle: () => throwError(() => new HttpErrorResponse({ status: 401 })), - }) - .subscribe({ error: () => undefined }); - expect(auth.authLogout).toHaveBeenCalledTimes(1); - }); }); From 52bed39c32d576b50bd2c518900352091ae42926 Mon Sep 17 00:00:00 2001 From: Zack Lee Date: Thu, 17 Sep 2026 12:08:06 -0400 Subject: [PATCH 44/61] fix: restore root table name spacing without batch checkboxes --- .../experiment-root-section-card-table.component.scss | 6 +++++- .../feature-flag-root-section-card-table.component.scss | 6 +++++- .../segment-root-section-card-table.component.scss | 6 +++++- 3 files changed, 15 insertions(+), 3 deletions(-) diff --git a/packages/frontend/projects/upgrade/src/app/features/dashboard/experiments/pages/experiment-root-page/experiment-root-page-content/experiment-root-section-card/experiment-root-section-card-table/experiment-root-section-card-table.component.scss b/packages/frontend/projects/upgrade/src/app/features/dashboard/experiments/pages/experiment-root-page/experiment-root-page-content/experiment-root-section-card/experiment-root-section-card-table/experiment-root-section-card-table.component.scss index ea11dd5e0..3dd394c37 100644 --- a/packages/frontend/projects/upgrade/src/app/features/dashboard/experiments/pages/experiment-root-page/experiment-root-page-content/experiment-root-section-card/experiment-root-section-card-table/experiment-root-section-card-table.component.scss +++ b/packages/frontend/projects/upgrade/src/app/features/dashboard/experiments/pages/experiment-root-page/experiment-root-page-content/experiment-root-section-card/experiment-root-section-card-table/experiment-root-section-card-table.component.scss @@ -65,7 +65,11 @@ .name-column { width: 22%; - padding-left: 8px; + padding-left: 32px; + + &:has(.batch-checkbox) { + padding-left: 8px; + } .experiment-name { color: var(--black-2); diff --git a/packages/frontend/projects/upgrade/src/app/features/dashboard/feature-flags/pages/feature-flag-root-page/feature-flag-root-page-content/feature-flag-root-section-card/feature-flag-root-section-card-table/feature-flag-root-section-card-table.component.scss b/packages/frontend/projects/upgrade/src/app/features/dashboard/feature-flags/pages/feature-flag-root-page/feature-flag-root-page-content/feature-flag-root-section-card/feature-flag-root-section-card-table/feature-flag-root-section-card-table.component.scss index b257ece6f..1a87dce29 100644 --- a/packages/frontend/projects/upgrade/src/app/features/dashboard/feature-flags/pages/feature-flag-root-page/feature-flag-root-page-content/feature-flag-root-section-card/feature-flag-root-section-card-table/feature-flag-root-section-card-table.component.scss +++ b/packages/frontend/projects/upgrade/src/app/features/dashboard/feature-flags/pages/feature-flag-root-page/feature-flag-root-page-content/feature-flag-root-section-card/feature-flag-root-section-card-table/feature-flag-root-section-card-table.component.scss @@ -65,7 +65,11 @@ .name-column { width: 22%; - padding-left: 8px; + padding-left: 32px; + + &:has(.batch-checkbox) { + padding-left: 8px; + } .flag-name { color: var(--black-2); diff --git a/packages/frontend/projects/upgrade/src/app/features/dashboard/segments/pages/segment-root-page/segment-root-page-content/segment-root-section-card/segment-root-section-card-table/segment-root-section-card-table.component.scss b/packages/frontend/projects/upgrade/src/app/features/dashboard/segments/pages/segment-root-page/segment-root-page-content/segment-root-section-card/segment-root-section-card-table/segment-root-section-card-table.component.scss index 3ffb78244..189490e6c 100644 --- a/packages/frontend/projects/upgrade/src/app/features/dashboard/segments/pages/segment-root-page/segment-root-page-content/segment-root-section-card/segment-root-section-card-table/segment-root-section-card-table.component.scss +++ b/packages/frontend/projects/upgrade/src/app/features/dashboard/segments/pages/segment-root-page/segment-root-page-content/segment-root-section-card/segment-root-section-card-table/segment-root-section-card-table.component.scss @@ -65,7 +65,11 @@ .name-column { width: 22%; - padding-left: 8px; + padding-left: 32px; + + &:has(.batch-checkbox) { + padding-left: 8px; + } .segment-name { color: var(--black-2); From 1633c746da9e46cbb625c84aa243fc049250b5e5 Mon Sep 17 00:00:00 2001 From: Zack Lee Date: Thu, 17 Sep 2026 12:56:21 -0400 Subject: [PATCH 45/61] fix: align batch table headers with existing UI conventions --- .../dashboard/batch-delete-ui.spec.ts | 43 +++++- ...ent-root-section-card-table.component.html | 142 ++++++++---------- ...ent-root-section-card-table.component.scss | 16 +- ...iment-root-section-card-table.component.ts | 2 +- ...xperiment-root-section-card.component.html | 2 +- ...lag-root-section-card-table.component.html | 138 ++++++++--------- ...lag-root-section-card-table.component.scss | 16 +- ...-flag-root-section-card-table.component.ts | 2 +- ...ture-flag-root-section-card.component.html | 2 +- ...ent-root-section-card-table.component.html | 142 ++++++++---------- ...ent-root-section-card-table.component.scss | 16 +- ...gment-root-section-card-table.component.ts | 2 +- .../segment-root-section-card.component.html | 2 +- .../src/styles/shared-batch-selection.scss | 25 +-- .../upgrade/src/styles/variables.scss | 1 + 15 files changed, 280 insertions(+), 271 deletions(-) diff --git a/packages/frontend/projects/upgrade/src/app/features/dashboard/batch-delete-ui.spec.ts b/packages/frontend/projects/upgrade/src/app/features/dashboard/batch-delete-ui.spec.ts index 0e386f5cd..004bc82b7 100644 --- a/packages/frontend/projects/upgrade/src/app/features/dashboard/batch-delete-ui.spec.ts +++ b/packages/frontend/projects/upgrade/src/app/features/dashboard/batch-delete-ui.spec.ts @@ -203,7 +203,14 @@ describe.each(cases)('$entity root batch UI', (config) => { TestBed.resetTestingModule(); }); - it('keeps checkbox clicks and Space separate from Name sorting and detail links', () => { + it('keeps Name sort semantics on the header and checkbox interactions separate from sorting', () => { + const nameHeader: HTMLElement = fixture.nativeElement.querySelector('th.name-column'); + const sortButton = nameHeader.querySelector('[role="button"]'); + expect(nameHeader.getAttribute('aria-sort')).toBe('ascending'); + expect(nameHeader.querySelector('[aria-sort]')).toBeNull(); + expect(nameHeader.querySelector('input[type=checkbox]')).toBeNull(); + expect(document.getElementById(sortButton.getAttribute('aria-describedby')).textContent).toBe('Sort by Name'); + const input = checkboxes()[1]; expect(input.getAttribute('aria-label')).toContain('Alpha'); input.dispatchEvent(new KeyboardEvent('keydown', { key: ' ', bubbles: true })); @@ -212,8 +219,30 @@ describe.each(cases)('$entity root batch UI', (config) => { expect(service.setSortKey).not.toHaveBeenCalled(); expect(fixture.nativeElement.textContent).toContain('1 Selected'); expect(fixture.nativeElement.querySelector('a').getAttribute('href')).toContain(rows[0].id); - fixture.nativeElement.querySelector('.batch-name-sort .mat-sort-header-container').click(); + const headerCheckbox = checkboxes()[0]; + expect(headerCheckbox.closest('[role="button"]')).toBeNull(); + headerCheckbox.dispatchEvent(new KeyboardEvent('keydown', { key: ' ', keyCode: 32, bubbles: true })); + headerCheckbox.click(); + fixture.detectChanges(); + expect(service.setSortKey).not.toHaveBeenCalled(); + + nameHeader.click(); + fixture.detectChanges(); expect(service.setSortKey).toHaveBeenCalledWith('name'); + expect(service.setSortKey).toHaveBeenCalledTimes(1); + expect(nameHeader.getAttribute('aria-sort')).toBe('descending'); + expect(nameHeader.querySelector('[aria-sort]')).toBeNull(); + + nameHeader.closest('table').parentElement.scroll = jest.fn(); + sortButton.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', keyCode: 13, bubbles: true })); + fixture.detectChanges(); + expect(service.setSortKey).toHaveBeenLastCalledWith(null); + expect(nameHeader.getAttribute('aria-sort')).toBe('none'); + + sortButton.dispatchEvent(new KeyboardEvent('keydown', { key: ' ', keyCode: 32, bubbles: true })); + fixture.detectChanges(); + expect(service.setSortKey).toHaveBeenLastCalledWith('name'); + expect(nameHeader.getAttribute('aria-sort')).toBe('ascending'); }); it('clears a mixed header visually and keeps subsequent select-all toggles synchronized', fakeAsync(() => { @@ -251,6 +280,9 @@ describe.each(cases)('$entity root batch UI', (config) => { expect(checkboxes()[0].indeterminate).toBe(true); expect(checkboxes()[0].getAttribute('aria-label')).toBe('Clear all selections'); expect(fixture.nativeElement.textContent).toContain('1 Selected'); + expect(fixture.nativeElement.querySelector('td[colspan]').colSpan).toBe( + fixture.nativeElement.querySelectorAll('th').length + ); checkboxes()[0].click(); fixture.detectChanges(); expect(Object.keys(batch().selectedById)).toHaveLength(0); @@ -294,10 +326,11 @@ describe.each(cases)('$entity root batch UI', (config) => { fixture.detectChanges(); expect(fixture.nativeElement.querySelectorAll('.batch-checkbox')).toHaveLength(0); expect(fixture.nativeElement.querySelector('.section-card-menu-trigger')).toBeNull(); - const nameCells: HTMLElement[] = [...fixture.nativeElement.querySelectorAll('.batch-name-cell')]; + expect(fixture.nativeElement.querySelector('.batch-select-column')).toBeNull(); + const nameCells: HTMLElement[] = [...fixture.nativeElement.querySelectorAll('.name-column')]; expect(nameCells).toHaveLength(rows.length + 1); - expect(nameCells.every((cell) => cell.children.length === 1)).toBe(true); - expect(nameCells[0].firstElementChild.classList.contains('batch-name-sort')).toBe(true); + expect(nameCells[0].querySelector('[role="button"]').textContent.trim()).toBe('Name'); + expect(nameCells[0].getAttribute('aria-sort')).toBe('ascending'); rows.forEach((row, index) => expect(nameCells[index + 1].querySelector('a').textContent).toContain(row.name)); }); diff --git a/packages/frontend/projects/upgrade/src/app/features/dashboard/experiments/pages/experiment-root-page/experiment-root-page-content/experiment-root-section-card/experiment-root-section-card-table/experiment-root-section-card-table.component.html b/packages/frontend/projects/upgrade/src/app/features/dashboard/experiments/pages/experiment-root-page/experiment-root-page-content/experiment-root-section-card/experiment-root-section-card-table/experiment-root-section-card-table.component.html index 4c244927b..48cfd1d57 100644 --- a/packages/frontend/projects/upgrade/src/app/features/dashboard/experiments/pages/experiment-root-page/experiment-root-page-content/experiment-root-section-card/experiment-root-section-card-table/experiment-root-section-card-table.component.html +++ b/packages/frontend/projects/upgrade/src/app/features/dashboard/experiments/pages/experiment-root-page/experiment-root-page-content/experiment-root-section-card/experiment-root-section-card-table/experiment-root-section-card-table.component.html @@ -9,98 +9,86 @@ [dataSource]="experiments || []" [trackBy]="trackByExperimentId" [class.has-batch-selection]="selection?.selectedCount" + [class.has-batch-controls]="canSelect" [ngClass]="{ 'no-data': !experiments?.length }" matSort (matSortChange)="changeSorting($event)" [matSortActive]="experimentSortKey$ | async" [matSortDirection]="experimentSortAs$ | async | lowercase" > + + +
+ + + + + -
- @if (canSelect) { - - - } -
- {{ - selection?.selectedCount - ? ('batch-delete.selection.count' | translate : { count: selection.selectedCount }) - : (EXPERIMENT_TRANSLATION_KEYS.NAME | translate) - }} -
-
+ {{ + selection?.selectedCount + ? ('batch-delete.selection.count' | translate : { count: selection.selectedCount }) + : (EXPERIMENT_TRANSLATION_KEYS.NAME | translate) + }}
-
- @if (canSelect) { - - } -
- - {{ experiment.name }} - - @if (experiment.description) { - - {{ experiment.description }} - - } -
-
+ + {{ experiment.name }} + + @if (experiment.description) { + + {{ experiment.description }} + + }
+ + + + + -
- @if (canSelect) { - - - } -
- {{ - selection?.selectedCount - ? ('batch-delete.selection.count' | translate : { count: selection.selectedCount }) - : (FLAG_TRANSLATION_KEYS.NAME | translate) - }} -
-
+ {{ + selection?.selectedCount + ? ('batch-delete.selection.count' | translate : { count: selection.selectedCount }) + : (FLAG_TRANSLATION_KEYS.NAME | translate) + }}
-
- @if (canSelect) { - - } -
- - {{ flag.name }} - - @if (flag.description) { - - {{ flag.description }} - - } -
-
+ + {{ flag.name }} + + @if (flag.description) { + + {{ flag.description }} + + }
+ + + + + -
- @if (canSelect) { - - - } -
- {{ - selection?.selectedCount - ? ('batch-delete.selection.count' | translate : { count: selection.selectedCount }) - : (SEGMENT_TRANSLATION_KEYS.NAME | translate) - }} -
-
+ {{ + selection?.selectedCount + ? ('batch-delete.selection.count' | translate : { count: selection.selectedCount }) + : (SEGMENT_TRANSLATION_KEYS.NAME | translate) + }}
-
- @if (canSelect) { - - } -
- - {{ segment.name }} - - @if (segment.description) { - - {{ segment.description }} - - } -
-
+ + {{ segment.name }} + + @if (segment.description) { + + {{ segment.description }} + + }
Date: Thu, 17 Sep 2026 18:15:01 -0400 Subject: [PATCH 53/61] fix: run segment cleanup after ambiguous batch commits --- .../src/api/services/BatchDeleteService.ts | 5 ++- .../unit/services/BatchDeleteService.test.ts | 38 +++++++++++++------ 2 files changed, 30 insertions(+), 13 deletions(-) diff --git a/packages/backend/src/api/services/BatchDeleteService.ts b/packages/backend/src/api/services/BatchDeleteService.ts index a47f64a2c..7f0405e76 100644 --- a/packages/backend/src/api/services/BatchDeleteService.ts +++ b/packages/backend/src/api/services/BatchDeleteService.ts @@ -149,8 +149,9 @@ export class BatchDeleteService { if (!transactionError || transactionError instanceof BatchDeleteSkippedError) transactionError = releaseError; } } - // A committed deletion must finish its post-commit work before surfacing a release failure. - if (transactionError && !committed) throw transactionError; + // Finish post-commit work after a confirmed commit or an ambiguous segment commit. + // Keep transactionError so the batch still stops and reports the original outcome. + if (transactionError && !committed && !(entity === 'segments' && commitAttempted)) throw transactionError; return response; }; try { diff --git a/packages/backend/test/unit/services/BatchDeleteService.test.ts b/packages/backend/test/unit/services/BatchDeleteService.test.ts index d22e655f5..29b680cf6 100644 --- a/packages/backend/test/unit/services/BatchDeleteService.test.ts +++ b/packages/backend/test/unit/services/BatchDeleteService.test.ts @@ -121,18 +121,29 @@ describe('BatchDeleteService transaction outcomes', () => { } }); - test.each([false, true])('awaits segment post-commit work (release fails: %s)', async (releaseFails) => { + const postCommitScenarios = ['success', 'release failure', 'commit response lost', 'commit rejected']; + test.each(postCommitScenarios)('awaits segment post-commit work after %s', async (scenario) => { const selectedIds = ids.slice(0, 2); - if (releaseFails) { - configureRunner = (runner) => { + const storedIds = new Set(selectedIds); + const commitFails = scenario === 'commit response lost' || scenario === 'commit rejected'; + const stopsBatch = scenario !== 'success'; + configureRunner = (runner, index) => { + const commit = (runner.commitTransaction as jest.Mock).getMockImplementation(); + (runner.commitTransaction as jest.Mock).mockImplementation(async () => { + if (scenario === 'commit rejected') throw new Error('commit rejected'); + storedIds.delete(selectedIds[index]); + if (scenario === 'commit response lost') throw new Error('commit response lost'); + await commit(); + }); + if (scenario === 'release failure') { (runner.release as jest.Mock).mockRejectedValue(new Error('release failed')); - }; - } - const saved = { flags: [] as string[], experiments: [] as string[] }; + } + }; + const saved = { flags: [...selectedIds], experiments: [...selectedIds] }; let releaseFirstWrites: () => void; const firstWrites = new Promise((resolve) => (releaseFirstWrites = resolve)); const recompute = (entity: keyof typeof saved) => async () => { - const members = selectedIds.filter((id) => !mutations.includes(id)); + const members = [...storedIds]; if (members.length) await firstWrites; saved[entity] = members; }; @@ -166,21 +177,26 @@ describe('BatchDeleteService transaction outcomes', () => { releaseFirstWrites(); await deletion; } - const remainingIds = releaseFails ? selectedIds.slice(1) : []; + const remainingIds = scenario === 'commit rejected' ? selectedIds : stopsBatch ? selectedIds.slice(1) : []; expect(saved).toEqual({ flags: remainingIds, experiments: remainingIds }); expect(segmentService.cacheService.resetPrefixCache).toHaveBeenCalledWith(CACHE_PREFIX.SEGMENT_KEY_PREFIX); expect(segmentService.cacheService.resetPrefixCache).toHaveBeenCalledWith( CACHE_PREFIX.GLOBAL_EXCLUDE_SEGMENT_KEY_PREFIX ); expect((await deletion).results).toEqual( - releaseFails + stopsBatch ? [ - { id: selectedIds[0], outcome: 'deleted', reasonCode: DeletionReasonCode.POST_DELETE_FAILED }, + { + id: selectedIds[0], + outcome: commitFails ? 'unknown' : 'deleted', + reasonCode: commitFails ? DeletionReasonCode.OUTCOME_UNKNOWN : DeletionReasonCode.POST_DELETE_FAILED, + }, { id: selectedIds[1], outcome: 'not_attempted' }, ] : selectedIds.map((id) => ({ id, outcome: 'deleted' })) ); - expect(createQueryRunner).toHaveBeenCalledTimes(releaseFails ? 1 : 2); + expect(createQueryRunner).toHaveBeenCalledTimes(stopsBatch ? 1 : 2); + if (commitFails) expect(runners[0].rollbackTransaction).toHaveBeenCalledTimes(1); }); test('stops without mutation when the target lookup fails', async () => { From 6e2ad6892fcdd7992c1141372d0a965c200f4fed Mon Sep 17 00:00:00 2001 From: Zack Lee Date: Thu, 17 Sep 2026 19:51:58 -0400 Subject: [PATCH 54/61] fix: make batch ID validation linear and remove unused pagination selectors --- .../validators/BatchEntityIdsValidator.ts | 20 +++++++++++-- .../test/integration/BatchDelete/index.ts | 12 +++++++- .../store/feature-flags.selectors.ts | 19 ------------ .../core/segments/store/segments.selectors.ts | 29 ------------------- 4 files changed, 29 insertions(+), 51 deletions(-) diff --git a/packages/backend/src/api/controllers/validators/BatchEntityIdsValidator.ts b/packages/backend/src/api/controllers/validators/BatchEntityIdsValidator.ts index 8eb755b9c..e949d2f9c 100644 --- a/packages/backend/src/api/controllers/validators/BatchEntityIdsValidator.ts +++ b/packages/backend/src/api/controllers/validators/BatchEntityIdsValidator.ts @@ -1,10 +1,26 @@ -import { ArrayNotEmpty, ArrayUnique, IsArray, IsUUID } from 'class-validator'; +import { ArrayNotEmpty, IsArray, IsUUID, registerDecorator } from 'class-validator'; import { BatchEntityIdsRequest } from 'upgrade_types'; +const HasUniqueIds = () => (object: object, propertyName: string) => { + registerDecorator({ + name: 'arrayUnique', + target: object.constructor, + propertyName, + options: { message: "All $property's elements must be unique" }, + validator: { + validate(value: unknown) { + if (!Array.isArray(value)) return false; + const normalizedIds = value.map((id: unknown) => (typeof id === 'string' ? id.toLowerCase() : id)); + return new Set(normalizedIds).size === value.length; + }, + }, + }); +}; + export class BatchEntityIdsValidator implements BatchEntityIdsRequest { @IsArray() @ArrayNotEmpty() - @ArrayUnique((id: unknown) => (typeof id === 'string' ? id.toLowerCase() : id)) + @HasUniqueIds() @IsUUID('all', { each: true }) public ids: string[]; } diff --git a/packages/backend/test/integration/BatchDelete/index.ts b/packages/backend/test/integration/BatchDelete/index.ts index d6aabbe88..41f03747e 100644 --- a/packages/backend/test/integration/BatchDelete/index.ts +++ b/packages/backend/test/integration/BatchDelete/index.ts @@ -138,7 +138,17 @@ export function registerBatchDeleteTests(connections: () => [DataSource, DataSou test.each(entities)('%s validates IDs and authentication before dispatch', async (entity) => { const spy = jest.spyOn(Container.get(BatchDeleteService), 'delete'); const id = randomUUID(); - for (const body of [{}, { ids: [] }, { ids: ['bad-id'] }, { ids: [id, id.toUpperCase()] }]) { + for (const body of [ + {}, + { ids: null }, + { ids: id }, + { ids: [] }, + { ids: [null] }, + { ids: [123] }, + { ids: ['bad-id'] }, + { ids: [id, id] }, + { ids: [id, id.toUpperCase()] }, + ]) { await request(app).post(route(entity)).send(body).expect(400); } env.google.authTokenRequired = true; diff --git a/packages/frontend/projects/upgrade/src/app/core/feature-flags/store/feature-flags.selectors.ts b/packages/frontend/projects/upgrade/src/app/core/feature-flags/store/feature-flags.selectors.ts index cdef42a44..ec293fedd 100644 --- a/packages/frontend/projects/upgrade/src/app/core/feature-flags/store/feature-flags.selectors.ts +++ b/packages/frontend/projects/upgrade/src/app/core/feature-flags/store/feature-flags.selectors.ts @@ -190,25 +190,6 @@ export const selectFeatureFlagExclusions = createSelector( } ); -export const selectFeatureFlagPaginationParams = createSelector( - selectSkipFlags, - selectTotalFlags, - selectSearchKey, - selectSortKey, - selectSortAs, - selectIsAllFlagsFetched, - selectSearchString, - (skip, total, searchKey, sortKey, sortAs, isAllFlagsFetched, searchString) => ({ - skip, - total, - searchKey, - sortKey, - sortAs, - isAllFlagsFetched, - searchString, - }) -); - // Helper function returns array of translation keys (extensible for future warning types) const getWarningKeysForFlag = (flag: FeatureFlag): string[] => { const warnings: string[] = []; diff --git a/packages/frontend/projects/upgrade/src/app/core/segments/store/segments.selectors.ts b/packages/frontend/projects/upgrade/src/app/core/segments/store/segments.selectors.ts index a140cfc83..765f4c194 100644 --- a/packages/frontend/projects/upgrade/src/app/core/segments/store/segments.selectors.ts +++ b/packages/frontend/projects/upgrade/src/app/core/segments/store/segments.selectors.ts @@ -122,16 +122,6 @@ export const selectSegmentOverviewDetails = createSelector(selectSelectedSegment ['Tags']: segment?.tags, })); -export const selectSkipSegments = createSelector(selectSegmentsState, (state) => state.skipSegments); - -export const selectTotalSegments = createSelector(selectSegmentsState, (state) => state.totalSegments); - -export const selectAreAllSegmentsFetched = createSelector( - selectSkipSegments, - selectTotalSegments, - (skipSegments, totalSegments) => skipSegments === totalSegments -); - export const selectSearchKey = createSelector(selectSegmentsState, (state) => state.searchKey); export const selectSearchString = createSelector(selectSegmentsState, (state) => state.searchString); @@ -222,25 +212,6 @@ export const selectSegmentUsageData = createSelector( } ); -export const selectSegmentPaginationParams = createSelector( - selectSkipSegments, - selectTotalSegments, - selectSearchKey, - selectSortKey, - selectSortAs, - selectAreAllSegmentsFetched, - selectSearchString, - (skip, total, searchKey, sortKey, sortAs, areAllFetched, searchString) => ({ - skip, - total, - searchKey, - sortKey, - sortAs, - areAllFetched, - searchString, - }) -); - export const selectListSegmentOptionsByContext = (context: string) => { return createSelector(selectSegmentsState, (segmentState: SegmentState) => { if (!segmentState?.listSegmentOptions) { From e80eb999b1305c44eb396ff338dd8c8f2661483a Mon Sep 17 00:00:00 2001 From: Zack Lee Date: Thu, 17 Sep 2026 20:28:55 -0400 Subject: [PATCH 55/61] fix: avoid argument limits when building batch deletion results --- .../src/api/services/BatchDeleteService.ts | 36 +++++++++---------- .../unit/services/BatchDeleteService.test.ts | 35 ++++++++++++++++++ 2 files changed, 51 insertions(+), 20 deletions(-) diff --git a/packages/backend/src/api/services/BatchDeleteService.ts b/packages/backend/src/api/services/BatchDeleteService.ts index 7f0405e76..3c04aaace 100644 --- a/packages/backend/src/api/services/BatchDeleteService.ts +++ b/packages/backend/src/api/services/BatchDeleteService.ts @@ -55,15 +55,13 @@ export class BatchDeleteService { let phase: BatchDeleteResult['phase'] = 'rejected'; for (const id of ids) { if (performance.now() >= deadline) { - results.push( - ...ids.slice(results.length).map( - (remaining): BatchDeleteItemResult => ({ - id: remaining, - outcome: 'not_attempted', - reasonCode: DeletionReasonCode.BATCH_BUDGET_EXCEEDED, - }) - ) - ); + for (let index = results.length; index < ids.length; index++) { + results.push({ + id: ids[index], + outcome: 'not_attempted', + reasonCode: DeletionReasonCode.BATCH_BUDGET_EXCEEDED, + }); + } break; } const result = await this.deleteOne(entity, id, user, logger, deadline); @@ -73,17 +71,15 @@ export class BatchDeleteService { if (result.outcome === 'not_found') continue; // A committed item with a post-delete failure must not be retried, but still stops this batch. if (result.outcome !== 'deleted' || result.reasonCode) { - results.push( - ...ids.slice(results.length).map( - (remaining): BatchDeleteItemResult => ({ - id: remaining, - outcome: 'not_attempted', - ...(result.reasonCode === DeletionReasonCode.BATCH_BUDGET_EXCEEDED - ? { reasonCode: DeletionReasonCode.BATCH_BUDGET_EXCEEDED } - : {}), - }) - ) - ); + for (let index = results.length; index < ids.length; index++) { + results.push({ + id: ids[index], + outcome: 'not_attempted', + ...(result.reasonCode === DeletionReasonCode.BATCH_BUDGET_EXCEEDED + ? { reasonCode: DeletionReasonCode.BATCH_BUDGET_EXCEEDED } + : {}), + }); + } break; } } diff --git a/packages/backend/test/unit/services/BatchDeleteService.test.ts b/packages/backend/test/unit/services/BatchDeleteService.test.ts index 29b680cf6..c8a78e4ff 100644 --- a/packages/backend/test/unit/services/BatchDeleteService.test.ts +++ b/packages/backend/test/unit/services/BatchDeleteService.test.ts @@ -408,4 +408,39 @@ describe('BatchDeleteService transaction outcomes', () => { expect(runners[0].rollbackTransaction).toHaveBeenCalled(); expect(logger.error).not.toHaveBeenCalled(); }); + + test.each(['failure', 'budget expiry'])('returns every result in a large batch after %s', async (stop) => { + // Fits within the 5MB JSON limit, but exceeds the argument limit of a spread-based push. + ids = Array.from({ length: 130_000 }, () => randomUUID()); + const budgetExpired = stop === 'budget expiry'; + work.mockImplementation(async (id) => { + if (!budgetExpired && id === ids[1]) throw new Error('cleanup failed'); + if (budgetExpired) (performance.now as jest.Mock).mockReturnValue(60_001); + return [{ id }]; + }); + + const result = await service.delete('flags', ids, user, logger); + expect(result.phase).toBe('executed'); + expect(result.results).toHaveLength(ids.length); + expect(result.results[0]).toEqual({ id: ids[0], outcome: 'deleted' }); + if (!budgetExpired) + expect(result.results[1]).toEqual({ + id: ids[1], + outcome: 'failed', + reasonCode: DeletionReasonCode.DELETE_FAILED, + }); + const remainingStart = budgetExpired ? 1 : 2; + expect( + result.results + .slice(remainingStart) + .every( + (item, index) => + item.id === ids[remainingStart + index] && + item.outcome === 'not_attempted' && + item.reasonCode === (budgetExpired ? DeletionReasonCode.BATCH_BUDGET_EXCEEDED : undefined) + ) + ).toBe(true); + expect(createQueryRunner).toHaveBeenCalledTimes(remainingStart); + expect(runners[0].commitTransaction).toHaveBeenCalledTimes(1); + }); }); From b76320cbd38c243e9fc8f804e238294dc5be061a Mon Sep 17 00:00:00 2001 From: Zack Lee Date: Thu, 17 Sep 2026 21:43:21 -0400 Subject: [PATCH 56/61] perf: use set lookups for batch response validation and selection --- .../batch-actions.helpers.spec.ts | 41 ++++++++++++------- .../batch-actions/batch-actions.helpers.ts | 17 +++++--- .../batch-actions/batch-actions.reducer.ts | 3 +- 3 files changed, 40 insertions(+), 21 deletions(-) diff --git a/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.helpers.spec.ts b/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.helpers.spec.ts index 3626b22fb..93dff9252 100644 --- a/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.helpers.spec.ts +++ b/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.helpers.spec.ts @@ -1,4 +1,10 @@ -import { BatchDeleteEntity, EXPERIMENT_STATE, FEATURE_FLAG_STATUS, SEGMENT_STATUS } from 'upgrade_types'; +import { + BatchDeleteEntity, + BatchDeleteResult, + EXPERIMENT_STATE, + FEATURE_FLAG_STATUS, + SEGMENT_STATUS, +} from 'upgrade_types'; import { createBatchActions } from './batch-actions.actions'; import { localDeletionReason, selectionView, validateBatchResponse } from './batch-actions.helpers'; import { @@ -86,21 +92,28 @@ describe('Root selection rules', () => { expect(localDeletionReason(entity, selected)).toBe(reason); }); - it('treats an incomplete or duplicate deletion response as uncertain rather than silently removing rows', () => { + it('accepts reordered results but rejects incomplete, duplicate, or unexpected result IDs', () => { + const complete: BatchDeleteResult = { + phase: 'executed', + results: [ + { id: 'b', outcome: 'not_found' }, + { id: 'a', outcome: 'deleted' }, + ], + }; + expect(validateBatchResponse(complete, ['a', 'b'])).toBe(complete); expect(() => validateBatchResponse({ phase: 'executed', results: [{ id: 'a', outcome: 'deleted' }] }, ['a', 'b']) ).toThrow(); - expect(() => - validateBatchResponse( - { - phase: 'executed', - results: [ - { id: 'a', outcome: 'deleted' }, - { id: 'a', outcome: 'deleted' }, - ], - }, - ['a', 'b'] - ) - ).toThrow(); + for (const returnedIds of [ + ['a', 'a'], + ['a', 'unexpected'], + ]) { + expect(() => + validateBatchResponse({ phase: 'executed', results: returnedIds.map((id) => ({ id, outcome: 'deleted' })) }, [ + 'a', + 'b', + ]) + ).toThrow(); + } }); }); diff --git a/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.helpers.ts b/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.helpers.ts index 91770055a..b503982e9 100644 --- a/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.helpers.ts +++ b/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.helpers.ts @@ -44,15 +44,15 @@ export function localDeletionReason( export function selectionView(state: RootBatchState, entity: BatchDeleteEntity) { const items = Object.values(state.selectedById); - const loaded = new Set(state.loadedIds); - const checked = loaded.size > 0 && [...loaded].every((id) => !!state.selectedById[id]); + const loaded = state.loadedIds; + const checked = loaded.length > 0 && loaded.every((id) => !!state.selectedById[id]); const reasons = items.map((item) => localDeletionReason(entity, item)).filter(Boolean); return { items, selectedCount: items.length, checked, indeterminate: items.length > 0 && !checked, - canToggleHeader: !isBatchBusy(state) && (items.length > 0 || loaded.size > 0), + canToggleHeader: !isBatchBusy(state) && (items.length > 0 || loaded.length > 0), canRequestConfirmation: items.length > 0 && !reasons.length && !isBatchBusy(state), reasonCode: reasons[0], busy: isBatchBusy(state), @@ -69,9 +69,14 @@ export function validateBatchResponse(result: BatchDeleteResult, ids: string[]): !result || !['rejected', 'executed'].includes(result.phase) || !Array.isArray(result.results) || - result.results.length !== ids.length || - new Set(result.results.map((item) => item.id)).size !== ids.length || - ids.some((id) => !result.results.some((item) => item.id === id)) || + result.results.length !== ids.length + ) { + throw new Error('Incomplete batch deletion response'); + } + const resultIds = new Set(result.results.map((item) => item.id)); + if ( + resultIds.size !== ids.length || + ids.some((id) => !resultIds.has(id)) || result.results.some((item) => !outcomes.includes(item.outcome)) ) { throw new Error('Incomplete batch deletion response'); diff --git a/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.reducer.ts b/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.reducer.ts index 539d3757f..56086b532 100644 --- a/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.reducer.ts +++ b/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.reducer.ts @@ -83,9 +83,10 @@ export function reduceRootBatch( : matches(action, actions.toggleHeader) ? action.items : []; + const loadedIds = new Set(state.loadedIds); for (const item of items) { if (selectedById[item.id] && matches(action, actions.toggleRow)) delete selectedById[item.id]; - else if (state.loadedIds.includes(item.id)) { + else if (loadedIds.has(item.id)) { selectedById[item.id] = { ...item }; } } From 6f92b8b6baefe33677ca41efeefb32cf1f98d5fb Mon Sep 17 00:00:00 2001 From: Zack Lee Date: Fri, 18 Sep 2026 00:08:07 -0400 Subject: [PATCH 57/61] refactor: align batch deletion policies with existing single-delete APIs --- .../api/controllers/ExperimentController.ts | 6 +- .../api/controllers/FeatureFlagController.ts | 6 +- .../src/api/controllers/SegmentController.ts | 8 +- .../api/repositories/DeletionRepository.ts | 15 ---- .../src/api/services/BatchDeleteService.ts | 45 +--------- .../test/integration/BatchDelete/index.ts | 40 +++++---- .../unit/services/BatchDeleteService.test.ts | 85 +++---------------- packages/types/src/BatchActions/index.ts | 9 -- packages/types/src/index.ts | 1 - 9 files changed, 40 insertions(+), 175 deletions(-) diff --git a/packages/backend/src/api/controllers/ExperimentController.ts b/packages/backend/src/api/controllers/ExperimentController.ts index 9b8f3f524..b16a806bb 100644 --- a/packages/backend/src/api/controllers/ExperimentController.ts +++ b/packages/backend/src/api/controllers/ExperimentController.ts @@ -694,14 +694,12 @@ export class ExperimentController { * '400': * description: Expected a nonempty array of unique UUIDs. * '401': - * description: A current authenticated user is required. - * '403': - * description: The current user cannot delete this entity type. + * description: AuthorizationRequiredError */ @Post('/batch-delete') public batchDelete( @Body({ validate: true }) { ids }: BatchEntityIdsValidator, - @CurrentUser({ required: true }) currentUser: UserDTO, + @CurrentUser() currentUser: UserDTO, @Req() request: AppRequest ): Promise { return this.batchDeleteService.delete('experiments', ids, currentUser, request.logger); diff --git a/packages/backend/src/api/controllers/FeatureFlagController.ts b/packages/backend/src/api/controllers/FeatureFlagController.ts index 0144678e1..7c036bb4e 100644 --- a/packages/backend/src/api/controllers/FeatureFlagController.ts +++ b/packages/backend/src/api/controllers/FeatureFlagController.ts @@ -186,14 +186,12 @@ export class FeatureFlagsController { * '400': * description: Expected a nonempty array of unique UUIDs. * '401': - * description: A current authenticated user is required. - * '403': - * description: The current user cannot delete this entity type. + * description: AuthorizationRequiredError */ @Post('/batch-delete') public batchDelete( @Body({ validate: true }) { ids }: BatchEntityIdsValidator, - @CurrentUser({ required: true }) currentUser: UserDTO, + @CurrentUser() currentUser: UserDTO, @Req() request: AppRequest ): Promise { return this.batchDeleteService.delete('flags', ids, currentUser, request.logger); diff --git a/packages/backend/src/api/controllers/SegmentController.ts b/packages/backend/src/api/controllers/SegmentController.ts index 7f941dca5..51b3b887c 100644 --- a/packages/backend/src/api/controllers/SegmentController.ts +++ b/packages/backend/src/api/controllers/SegmentController.ts @@ -67,7 +67,7 @@ interface SegmentPaginationInfo extends PaginationResponse { * enum: [deleted, not_found, failed, unknown, not_attempted] * reasonCode: * type: string - * enum: [not_found, delete_failed, lock_timeout, external_sync_failed, outcome_unknown, post_delete_failed, batch_budget_exceeded] + * enum: [not_found, delete_failed, lock_timeout, external_sync_failed, outcome_unknown, post_delete_failed] * BatchDeleteResult: * type: object * required: [phase, results] @@ -301,14 +301,12 @@ export class SegmentController { * '400': * description: Expected a nonempty array of unique UUIDs. * '401': - * description: A current authenticated user is required. - * '403': - * description: The current user cannot delete this entity type. + * description: AuthorizationRequiredError */ @Post('/batch-delete') public batchDelete( @Body({ validate: true }) { ids }: BatchEntityIdsValidator, - @CurrentUser({ required: true }) currentUser: UserDTO, + @CurrentUser() currentUser: UserDTO, @Req() request: AppRequest ): Promise { return this.batchDeleteService.delete('segments', ids, currentUser, request.logger); diff --git a/packages/backend/src/api/repositories/DeletionRepository.ts b/packages/backend/src/api/repositories/DeletionRepository.ts index 636f8cfa4..6f6ca8d5a 100644 --- a/packages/backend/src/api/repositories/DeletionRepository.ts +++ b/packages/backend/src/api/repositories/DeletionRepository.ts @@ -9,21 +9,6 @@ import repositoryError from './utils/repositoryError'; /** All reads and locks use the caller's deletion transaction, never the repository's default manager. */ @EntityRepository() export class DeletionRepository extends Repository { - public async setLockTimeout(manager: EntityManager): Promise { - // Cap each lock wait through cleanup/deletion; preserve any tighter caller/database limit. - // Transaction-local settings expire on commit/rollback. This is not a total request deadline. - await manager - .query( - `SELECT set_config('lock_timeout', CASE - WHEN current_setting('lock_timeout')::interval = interval '0' - OR current_setting('lock_timeout')::interval > interval '5 seconds' - THEN '5s' ELSE current_setting('lock_timeout') END, true)` - ) - .catch((errorMsg: any) => { - throw repositoryError('DeletionRepository', 'setLockTimeout', {}, errorMsg); - }); - } - public async findForDeletion( entity: BatchDeleteEntity, id: string, diff --git a/packages/backend/src/api/services/BatchDeleteService.ts b/packages/backend/src/api/services/BatchDeleteService.ts index 3c04aaace..7b7884713 100644 --- a/packages/backend/src/api/services/BatchDeleteService.ts +++ b/packages/backend/src/api/services/BatchDeleteService.ts @@ -1,14 +1,6 @@ -import { performance } from 'perf_hooks'; import { Inject, Service } from 'typedi'; import { DataSource, EntityManager } from 'typeorm'; -import { ForbiddenError, UnauthorizedError } from 'routing-controllers'; -import { - BatchDeleteEntity, - BatchDeleteItemResult, - BatchDeleteResult, - DeletionReasonCode, - hasBatchDeletePermission, -} from 'upgrade_types'; +import { BatchDeleteEntity, BatchDeleteItemResult, BatchDeleteResult, DeletionReasonCode } from 'upgrade_types'; import { env } from '../../env'; import { UpgradeLogger } from '../../lib/logger/UpgradeLogger'; import { InjectDataSource, InjectRepository } from '../../typeorm-typedi-extensions'; @@ -21,10 +13,6 @@ import { MoocletExperimentService } from './MoocletExperimentService'; import { SegmentService } from './SegmentService'; import { DeletionRepository } from '../repositories/DeletionRepository'; -// Admission budget: never abandon an in-flight deletion or claim it has been cancelled. -// Check before starting another item and again after acquiring its target lock. -export const BATCH_DELETE_START_BUDGET_MS = 60_000; - class BatchDeleteSkippedError extends Error { constructor(public readonly result: BatchDeleteItemResult) { super(result.reasonCode); @@ -48,25 +36,12 @@ export class BatchDeleteService { user: UserDTO, logger: UpgradeLogger ): Promise { - if (!user) throw new UnauthorizedError('A current user is required'); - if (!hasBatchDeletePermission(user.role, entity)) throw new ForbiddenError('Deletion permission is required'); - const deadline = performance.now() + BATCH_DELETE_START_BUDGET_MS; const results: BatchDeleteItemResult[] = []; let phase: BatchDeleteResult['phase'] = 'rejected'; for (const id of ids) { - if (performance.now() >= deadline) { - for (let index = results.length; index < ids.length; index++) { - results.push({ - id: ids[index], - outcome: 'not_attempted', - reasonCode: DeletionReasonCode.BATCH_BUDGET_EXCEEDED, - }); - } - break; - } - const result = await this.deleteOne(entity, id, user, logger, deadline); + const result = await this.deleteOne(entity, id, user, logger); results.push(result); - if (result.outcome !== 'not_found' && result.outcome !== 'not_attempted') phase = 'executed'; + if (result.outcome !== 'not_found') phase = 'executed'; // An already absent target does not prevent independent items from being deleted. if (result.outcome === 'not_found') continue; // A committed item with a post-delete failure must not be retried, but still stops this batch. @@ -75,9 +50,6 @@ export class BatchDeleteService { results.push({ id: ids[index], outcome: 'not_attempted', - ...(result.reasonCode === DeletionReasonCode.BATCH_BUDGET_EXCEEDED - ? { reasonCode: DeletionReasonCode.BATCH_BUDGET_EXCEEDED } - : {}), }); } break; @@ -90,8 +62,7 @@ export class BatchDeleteService { entity: BatchDeleteEntity, id: string, user: UserDTO, - logger: UpgradeLogger, - deadline: number + logger: UpgradeLogger ): Promise { let committed = false; let rolledBack = false; @@ -104,18 +75,10 @@ export class BatchDeleteService { try { await runner.connect(); await runner.startTransaction('READ COMMITTED'); - await this.deletionRepository.setLockTimeout(runner.manager); const target = await this.deletionRepository.findForDeletion(entity, id, runner.manager); if (!target) { throw new BatchDeleteSkippedError({ id, outcome: 'not_found', reasonCode: DeletionReasonCode.NOT_FOUND }); } - if (performance.now() >= deadline) { - throw new BatchDeleteSkippedError({ - id, - outcome: 'not_attempted', - reasonCode: DeletionReasonCode.BATCH_BUDGET_EXCEEDED, - }); - } mutationStarted = true; response = await work(runner.manager); // Legacy experiment/flag repositories return arrays despite their declared return types. diff --git a/packages/backend/test/integration/BatchDelete/index.ts b/packages/backend/test/integration/BatchDelete/index.ts index 41f03747e..d07fb1206 100644 --- a/packages/backend/test/integration/BatchDelete/index.ts +++ b/packages/backend/test/integration/BatchDelete/index.ts @@ -135,7 +135,7 @@ export function registerBatchDeleteTests(connections: () => [DataSource, DataSou return list; }; - test.each(entities)('%s validates IDs and authentication before dispatch', async (entity) => { + test.each(entities)('%s validates IDs and follows single-delete authentication', async (entity) => { const spy = jest.spyOn(Container.get(BatchDeleteService), 'delete'); const id = randomUUID(); for (const body of [ @@ -156,33 +156,31 @@ export function registerBatchDeleteTests(connections: () => [DataSource, DataSou .post(route(entity)) .send({ ids: [id] }) .expect(401); + expect(spy).not.toHaveBeenCalled(); + env.google.authTokenRequired = false; await db.getRepository(User).delete({ email: SYSTEM_USER_EMAIL }); - await request(app) + const [single, batch] = await create(entity, 2); + await request(app).delete(`/api/${entity}/${single.id}`).expect(200); + const { body } = await request(app) .post(route(entity)) - .send({ ids: [id] }) - .expect(401); - expect(spy).not.toHaveBeenCalled(); + .send({ ids: [batch.id] }) + .expect(200); + expect(body.results).toEqual([{ id: batch.id, outcome: 'deleted' }]); + expect(await db.getRepository(model[entity]).countBy({ id: In([single.id, batch.id]) })).toBe(0); }); - test.each(Object.values(UserRole))('enforces server-side %s permissions despite body overrides', async (role) => { + test.each(Object.values(UserRole))('matches single-delete behavior for the %s role', async (role) => { await db.getRepository(User).update(SYSTEM_USER_EMAIL, { role }); for (const entity of entities) { - const [row] = await create(entity); - const allowed = - role === UserRole.ADMIN || - role === UserRole.CREATOR || - (role === UserRole.USER_MANAGER && entity === 'segments'); + const [single, batch] = await create(entity, 2); + await request(app).delete(`/api/${entity}/${single.id}`).expect(200); const { body } = await request(app) .post(route(entity)) - .send({ - ids: [row.id], - role: UserRole.ADMIN, - canDelete: true, - }) - .expect(allowed ? 200 : 403); - if (allowed) expect(body.results).toEqual([{ id: row.id, outcome: 'deleted' }]); - expect(await db.getRepository(model[entity]).countBy({ id: row.id })).toBe(allowed ? 0 : 1); + .send({ ids: [batch.id] }) + .expect(200); + expect(body.results).toEqual([{ id: batch.id, outcome: 'deleted' }]); + expect(await db.getRepository(model[entity]).countBy({ id: In([single.id, batch.id]) })).toBe(0); } }); @@ -356,7 +354,7 @@ export function registerBatchDeleteTests(connections: () => [DataSource, DataSou } }); - test('bounds a target lock wait and reports a confirmed failure without deleting', async () => { + test('reports a database-configured lock timeout without deleting', async () => { const [row] = await create('flags'); const blocker = writer.createQueryRunner(); await blocker.connect(); @@ -369,7 +367,7 @@ export function registerBatchDeleteTests(connections: () => [DataSource, DataSou const start = runner.startTransaction.bind(runner); jest.spyOn(runner, 'startTransaction').mockImplementation(async (...startArgs) => { await start(...startArgs); - // A tighter deployment default must survive the batch's five-second cap. + // Model a database-configured limit; the deletion service does not set its own timeout. await runner.query("SET LOCAL lock_timeout = '100ms'"); }); return runner; diff --git a/packages/backend/test/unit/services/BatchDeleteService.test.ts b/packages/backend/test/unit/services/BatchDeleteService.test.ts index c8a78e4ff..0c44a3dcb 100644 --- a/packages/backend/test/unit/services/BatchDeleteService.test.ts +++ b/packages/backend/test/unit/services/BatchDeleteService.test.ts @@ -1,5 +1,4 @@ import { randomUUID } from 'crypto'; -import { performance } from 'perf_hooks'; import { DataSource, EntityManager, QueryRunner } from 'typeorm'; import { BatchDeleteEntity, CACHE_PREFIX, DeletionReasonCode, UserRole } from 'upgrade_types'; import { BatchDeleteService } from '../../../src/api/services/BatchDeleteService'; @@ -14,8 +13,6 @@ import { UpgradeLogger } from '../../../src/lib/logger/UpgradeLogger'; import { env } from '../../../src/env'; import { DeletionRepository } from '../../../src/api/repositories/DeletionRepository'; -jest.mock('perf_hooks', () => ({ performance: { now: jest.fn(() => 0) } })); - describe('BatchDeleteService transaction outcomes', () => { const entities: BatchDeleteEntity[] = ['experiments', 'flags', 'segments']; const user = { email: 'batch@example.com', firstName: 'Batch', lastName: 'User', role: UserRole.ADMIN }; @@ -38,7 +35,6 @@ describe('BatchDeleteService transaction outcomes', () => { ids = [randomUUID(), randomUUID(), randomUUID()]; mutations = []; runners = []; - (performance.now as jest.Mock).mockReturnValue(0); originalMooclet = env.mooclets.enabled; env.mooclets.enabled = false; configureRunner = () => undefined; @@ -49,7 +45,6 @@ describe('BatchDeleteService transaction outcomes', () => { return active; }, manager: { - query: jest.fn().mockResolvedValue([]), getRepository: jest.fn(() => ({ findOne: jest.fn(async ({ where }) => ({ id: where.id })), })), @@ -102,14 +97,6 @@ describe('BatchDeleteService transaction outcomes', () => { jest.restoreAllMocks(); }); - test.each(entities)('%s rejects unauthorized users before any transaction or mutation', async (entity) => { - await expect(service.delete(entity, ids, undefined, logger)).rejects.toMatchObject({ httpCode: 401 }); - for (const role of [UserRole.READER, undefined, 'unknown' as UserRole]) { - await expect(service.delete(entity, ids, { ...user, role }, logger)).rejects.toMatchObject({ httpCode: 403 }); - } - expect(createQueryRunner).not.toHaveBeenCalled(); - }); - test.each(entities)('%s commits sequentially and normalizes existing service responses', async (entity) => { const result = await service.delete(entity, ids, user, logger); expect(result).toEqual({ phase: 'executed', results: ids.map((id) => ({ id, outcome: 'deleted' })) }); @@ -363,59 +350,11 @@ describe('BatchDeleteService transaction outcomes', () => { expect(mooclets.syncDelete).not.toHaveBeenCalled(); }); - test('stops starting items after the budget, without abandoning a committed item', async () => { - work.mockImplementation(async (id) => { - (performance.now as jest.Mock).mockReturnValue(60_001); - return [{ id }]; - }); - const result = await service.delete('flags', ids, user, logger); - expect(result.results).toEqual([ - { id: ids[0], outcome: 'deleted' }, - ...ids - .slice(1) - .map((id) => ({ id, outcome: 'not_attempted', reasonCode: DeletionReasonCode.BATCH_BUDGET_EXCEEDED })), - ]); - expect(createQueryRunner).toHaveBeenCalledTimes(1); - }); - - test('does not start execution if the admission budget has expired', async () => { - (performance.now as jest.Mock).mockReturnValueOnce(0).mockReturnValue(60_001); - expect(await service.delete('flags', ids, user, logger)).toEqual({ - phase: 'rejected', - results: ids.map((id) => ({ - id, - outcome: 'not_attempted', - reasonCode: DeletionReasonCode.BATCH_BUDGET_EXCEEDED, - })), - }); - expect(createQueryRunner).not.toHaveBeenCalled(); - }); - - test('does not mutate when the budget expires while acquiring the target lock', async () => { - configureRunner = (runner) => { - (runner.manager.getRepository as jest.Mock).mockReturnValue({ - findOne: jest.fn(async () => { - (performance.now as jest.Mock).mockReturnValue(60_001); - return { id: ids[0] }; - }), - }); - }; - const result = await service.delete('flags', ids, user, logger); - expect(result.results).toEqual( - ids.map((id) => ({ id, outcome: 'not_attempted', reasonCode: DeletionReasonCode.BATCH_BUDGET_EXCEEDED })) - ); - expect(mutations).toEqual([]); - expect(runners[0].rollbackTransaction).toHaveBeenCalled(); - expect(logger.error).not.toHaveBeenCalled(); - }); - - test.each(['failure', 'budget expiry'])('returns every result in a large batch after %s', async (stop) => { + test('returns every result in a large batch after failure', async () => { // Fits within the 5MB JSON limit, but exceeds the argument limit of a spread-based push. ids = Array.from({ length: 130_000 }, () => randomUUID()); - const budgetExpired = stop === 'budget expiry'; work.mockImplementation(async (id) => { - if (!budgetExpired && id === ids[1]) throw new Error('cleanup failed'); - if (budgetExpired) (performance.now as jest.Mock).mockReturnValue(60_001); + if (id === ids[1]) throw new Error('cleanup failed'); return [{ id }]; }); @@ -423,24 +362,20 @@ describe('BatchDeleteService transaction outcomes', () => { expect(result.phase).toBe('executed'); expect(result.results).toHaveLength(ids.length); expect(result.results[0]).toEqual({ id: ids[0], outcome: 'deleted' }); - if (!budgetExpired) - expect(result.results[1]).toEqual({ - id: ids[1], - outcome: 'failed', - reasonCode: DeletionReasonCode.DELETE_FAILED, - }); - const remainingStart = budgetExpired ? 1 : 2; + expect(result.results[1]).toEqual({ + id: ids[1], + outcome: 'failed', + reasonCode: DeletionReasonCode.DELETE_FAILED, + }); expect( result.results - .slice(remainingStart) + .slice(2) .every( (item, index) => - item.id === ids[remainingStart + index] && - item.outcome === 'not_attempted' && - item.reasonCode === (budgetExpired ? DeletionReasonCode.BATCH_BUDGET_EXCEEDED : undefined) + item.id === ids[2 + index] && item.outcome === 'not_attempted' && item.reasonCode === undefined ) ).toBe(true); - expect(createQueryRunner).toHaveBeenCalledTimes(remainingStart); + expect(createQueryRunner).toHaveBeenCalledTimes(2); expect(runners[0].commitTransaction).toHaveBeenCalledTimes(1); }); }); diff --git a/packages/types/src/BatchActions/index.ts b/packages/types/src/BatchActions/index.ts index b93f4b56a..f98ae8112 100644 --- a/packages/types/src/BatchActions/index.ts +++ b/packages/types/src/BatchActions/index.ts @@ -1,5 +1,3 @@ -import { UserRole } from '../Experiment/enums'; - export interface BatchEntityIdsRequest { ids: string[]; } @@ -11,7 +9,6 @@ export enum DeletionReasonCode { EXTERNAL_SYNC_FAILED = 'external_sync_failed', OUTCOME_UNKNOWN = 'outcome_unknown', POST_DELETE_FAILED = 'post_delete_failed', - BATCH_BUDGET_EXCEEDED = 'batch_budget_exceeded', } export type BatchDeleteEntity = 'experiments' | 'flags' | 'segments'; @@ -28,9 +25,3 @@ export interface BatchDeleteResult { phase: 'rejected' | 'executed'; results: BatchDeleteItemResult[]; } - -export function hasBatchDeletePermission(role: UserRole, entity: BatchDeleteEntity): boolean { - return ( - role === UserRole.ADMIN || role === UserRole.CREATOR || (entity === 'segments' && role === UserRole.USER_MANAGER) - ); -} diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index e4ff84e69..974f524f3 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -97,7 +97,6 @@ export { SYSTEM_USER_EMAIL, DEV_USER_EMAIL, FAKE_DEV_CREDENTIAL } from './User'; export { BatchEntityIdsRequest, BatchDeleteEntity, - hasBatchDeletePermission, DeletionReasonCode, BatchDeleteItemOutcome, BatchDeleteItemResult, From 4768e3fccc52ef7006eabfab71ff7a495a8662cf Mon Sep 17 00:00:00 2001 From: Zack Lee Date: Fri, 18 Sep 2026 00:56:20 -0400 Subject: [PATCH 58/61] perf: use set membership for batch row checkbox bindings --- .../src/app/core/batch-actions/batch-actions.facade.ts | 7 ++++++- .../experiment-root-section-card-table.component.html | 2 +- .../feature-flag-root-section-card-table.component.html | 2 +- .../segment-root-section-card-table.component.html | 2 +- 4 files changed, 9 insertions(+), 4 deletions(-) diff --git a/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.facade.ts b/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.facade.ts index 7206a8780..16b5f1366 100644 --- a/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.facade.ts +++ b/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.facade.ts @@ -16,7 +16,12 @@ export function createBatchFacade( const state$ = store.pipe(select(selectState)); return { state$, - selection$: state$.pipe(map((state) => selectionView(state, entity))), + selection$: state$.pipe( + map((state) => ({ + ...selectionView(state, entity), + loadedIds: new Set(state.loadedIds), + })) + ), toggleRow: (row: Parameters[0]) => store.dispatch(actions.toggleRow({ item: selectionItem(row) })), toggleHeader: () => diff --git a/packages/frontend/projects/upgrade/src/app/features/dashboard/experiments/pages/experiment-root-page/experiment-root-page-content/experiment-root-section-card/experiment-root-section-card-table/experiment-root-section-card-table.component.html b/packages/frontend/projects/upgrade/src/app/features/dashboard/experiments/pages/experiment-root-page/experiment-root-page-content/experiment-root-section-card/experiment-root-section-card-table/experiment-root-section-card-table.component.html index be29c1e21..5f0c839ca 100644 --- a/packages/frontend/projects/upgrade/src/app/features/dashboard/experiments/pages/experiment-root-page/experiment-root-page-content/experiment-root-section-card/experiment-root-section-card-table/experiment-root-section-card-table.component.html +++ b/packages/frontend/projects/upgrade/src/app/features/dashboard/experiments/pages/experiment-root-page/experiment-root-page-content/experiment-root-section-card/experiment-root-section-card-table/experiment-root-section-card-table.component.html @@ -47,7 +47,7 @@ [disableRipple]="true" color="primary" [checked]="!!batchState?.selectedById[experiment.id]" - [disabled]="selection?.busy || !batchState?.loadedIds.includes(experiment.id)" + [disabled]="selection?.busy || !selection?.loadedIds.has(experiment.id)" [aria-label]="'batch-delete.selection.experiments.row' | translate : { name: experiment.name }" (click)="$event.stopPropagation()" (keydown)="$event.stopPropagation()" diff --git a/packages/frontend/projects/upgrade/src/app/features/dashboard/feature-flags/pages/feature-flag-root-page/feature-flag-root-page-content/feature-flag-root-section-card/feature-flag-root-section-card-table/feature-flag-root-section-card-table.component.html b/packages/frontend/projects/upgrade/src/app/features/dashboard/feature-flags/pages/feature-flag-root-page/feature-flag-root-page-content/feature-flag-root-section-card/feature-flag-root-section-card-table/feature-flag-root-section-card-table.component.html index cdd55679e..37bf925a6 100644 --- a/packages/frontend/projects/upgrade/src/app/features/dashboard/feature-flags/pages/feature-flag-root-page/feature-flag-root-page-content/feature-flag-root-section-card/feature-flag-root-section-card-table/feature-flag-root-section-card-table.component.html +++ b/packages/frontend/projects/upgrade/src/app/features/dashboard/feature-flags/pages/feature-flag-root-page/feature-flag-root-page-content/feature-flag-root-section-card/feature-flag-root-section-card-table/feature-flag-root-section-card-table.component.html @@ -47,7 +47,7 @@ [disableRipple]="true" color="primary" [checked]="!!batchState?.selectedById[flag.id]" - [disabled]="selection?.busy || !batchState?.loadedIds.includes(flag.id)" + [disabled]="selection?.busy || !selection?.loadedIds.has(flag.id)" [aria-label]="'batch-delete.selection.flags.row' | translate : { name: flag.name }" (click)="$event.stopPropagation()" (keydown)="$event.stopPropagation()" diff --git a/packages/frontend/projects/upgrade/src/app/features/dashboard/segments/pages/segment-root-page/segment-root-page-content/segment-root-section-card/segment-root-section-card-table/segment-root-section-card-table.component.html b/packages/frontend/projects/upgrade/src/app/features/dashboard/segments/pages/segment-root-page/segment-root-page-content/segment-root-section-card/segment-root-section-card-table/segment-root-section-card-table.component.html index 7d234f879..8f7e0b839 100644 --- a/packages/frontend/projects/upgrade/src/app/features/dashboard/segments/pages/segment-root-page/segment-root-page-content/segment-root-section-card/segment-root-section-card-table/segment-root-section-card-table.component.html +++ b/packages/frontend/projects/upgrade/src/app/features/dashboard/segments/pages/segment-root-page/segment-root-page-content/segment-root-section-card/segment-root-section-card-table/segment-root-section-card-table.component.html @@ -47,7 +47,7 @@ [disableRipple]="true" color="primary" [checked]="!!batchState?.selectedById[segment.id]" - [disabled]="selection?.busy || !batchState?.loadedIds.includes(segment.id)" + [disabled]="selection?.busy || !selection?.loadedIds.has(segment.id)" [aria-label]="'batch-delete.selection.segments.row' | translate : { name: segment.name }" (click)="$event.stopPropagation()" (keydown)="$event.stopPropagation()" From b4f2b7ed3c3062d185763363320c92030ef982d9 Mon Sep 17 00:00:00 2001 From: Zack Lee Date: Fri, 18 Sep 2026 01:21:36 -0400 Subject: [PATCH 59/61] fix: align flag selection IDs with first-page reloads --- .../core/batch-actions/batch-actions.integration.spec.ts | 9 +++++++++ .../core/feature-flags/store/feature-flags.effects.ts | 2 +- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.integration.spec.ts b/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.integration.spec.ts index d84746d23..f5cf1b9ab 100644 --- a/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.integration.spec.ts +++ b/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.integration.spec.ts @@ -712,6 +712,15 @@ describe.each(fixtures)('$entity batch store/effects integration', (config) => { expect(notifications.showWarning).toHaveBeenCalledTimes(1); expect(notifications.showSuccess).not.toHaveBeenCalled(); expect(notifications.showError).not.toHaveBeenCalled(); + + // Scrolling retries from offset zero after the replacement request failed. + data[config.fetchMethod].mockReturnValue(of(page([rows[1]]))); + store.dispatch(config.fetch({ fromStarting: false })); + expect(data[config.fetchMethod]).toHaveBeenLastCalledWith(expect.objectContaining({ skip: 0 }), false); + expect(batch().loadedIds).toEqual(currentRows().map(({ id }) => id)); + store.dispatch(actions.toggleRow({ item: selectionItem(rows[1]) })); + store.dispatch(actions.toggleHeader({ items: currentRows().map(selectionItem) })); + expect(selectionView(batch(), config.entity)).toMatchObject({ checked: true, indeterminate: false }); }); it('retains an unknown item after refreshing the list and reports the confirmed results once', () => { 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 0e62a0ab0..bc999e277 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 @@ -68,7 +68,7 @@ export class FeatureFlagsEffects { (action.fromStarting || state.totalFlags === null || state.skipFlags < state.totalFlags) ), switchMap(([action, state]) => { - const fromStarting = !!action.fromStarting; + const fromStarting = !!action.fromStarting || state.skipFlags === 0; const params = { skip: fromStarting ? 0 : state.skipFlags, take: NUMBER_OF_FLAGS, From 22b278f33e3e5d13af7aa18dc8b88b7fae3433fd Mon Sep 17 00:00:00 2001 From: Zack Lee Date: Fri, 18 Sep 2026 02:12:22 -0400 Subject: [PATCH 60/61] fix: treat a zero list offset as a replacement --- .../batch-actions.integration.spec.ts | 2 ++ .../store/experiments.effects.spec.ts | 24 ++++++++++--------- .../experiments/store/experiments.effects.ts | 2 +- .../core/segments/store/segments.effects.ts | 2 +- 4 files changed, 17 insertions(+), 13 deletions(-) diff --git a/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.integration.spec.ts b/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.integration.spec.ts index f5cf1b9ab..0bdf48e4b 100644 --- a/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.integration.spec.ts +++ b/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.integration.spec.ts @@ -717,6 +717,8 @@ describe.each(fixtures)('$entity batch store/effects integration', (config) => { data[config.fetchMethod].mockReturnValue(of(page([rows[1]]))); store.dispatch(config.fetch({ fromStarting: false })); expect(data[config.fetchMethod]).toHaveBeenLastCalledWith(expect.objectContaining({ skip: 0 }), false); + // A zero offset replaces the retained rows instead of appending the first page to them. + expect(currentRows().map(({ id }) => id)).toEqual([rows[1].id]); expect(batch().loadedIds).toEqual(currentRows().map(({ id }) => id)); store.dispatch(actions.toggleRow({ item: selectionItem(rows[1]) })); store.dispatch(actions.toggleHeader({ items: currentRows().map(selectionItem) })); 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 3fc1c6da7..4659801fb 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 @@ -145,16 +145,15 @@ describe('ExperimentEffects', () => { it.each([false, true])( 'correlates root rows with the request and fetches stats (replacement=%s)', (fromStarting) => { - if (fromStarting) { - store$.next({ - experiments: { - ...initialExperimentState, - skipExperiment: 2, - totalExperiments: 1, - searchString: 'test', - }, - }); - } + // A nonzero offset is required for pagination, since a zero offset is itself a replacement. + store$.next({ + experiments: { + ...initialExperimentState, + skipExperiment: 2, + totalExperiments: fromStarting ? 1 : 3, + searchString: 'test', + }, + }); const experiments = [{ id: 'test1' } as any]; experimentDataService.getAllExperiment = jest.fn().mockReturnValue(of({ nodes: experiments, total: 1 })); const results = []; @@ -170,7 +169,10 @@ describe('ExperimentEffects', () => { actionFetchExperimentStats({ experimentIds: ['test1'] }), ]); expect(experimentDataService.getAllExperiment).toHaveBeenCalledWith( - expect.objectContaining({ skip: 0, searchParams: { key: initialExperimentState.searchKey, string: 'test' } }), + expect.objectContaining({ + skip: fromStarting ? 0 : 2, + searchParams: { key: initialExperimentState.searchKey, string: 'test' }, + }), false ); subscription.unsubscribe(); 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 1938321ba..4bfbf3440 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 @@ -91,7 +91,7 @@ export class ExperimentEffects { (action.fromStarting || state.totalExperiments === null || state.skipExperiment < state.totalExperiments) ), switchMap(([action, state]) => { - const fromStarting = !!action.fromStarting; + const fromStarting = !!action.fromStarting || state.skipExperiment === 0; const params = { skip: fromStarting ? 0 : state.skipExperiment, take: NUMBER_OF_EXPERIMENTS, 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 a105c8341..64c68f318 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 @@ -72,7 +72,7 @@ export class SegmentsEffects { (action.fromStarting || state.totalSegments === null || state.skipSegments < state.totalSegments) ), switchMap(([action, state]) => { - const fromStarting = !!action.fromStarting; + const fromStarting = !!action.fromStarting || state.skipSegments === 0; const params = { skip: fromStarting ? 0 : state.skipSegments, take: NUMBER_OF_SEGMENTS, From 42c935a4eeba4a3b61155b19680dc968556c56e2 Mon Sep 17 00:00:00 2001 From: Zack Lee Date: Mon, 21 Sep 2026 12:50:18 -0400 Subject: [PATCH 61/61] refactor: remove unused phase from batch deletion responses --- .../src/api/controllers/ExperimentController.ts | 2 +- .../api/controllers/FeatureFlagController.ts | 2 +- .../src/api/controllers/SegmentController.ts | 7 ++----- .../src/api/services/BatchDeleteService.ts | 4 +--- .../test/integration/BatchDelete/index.ts | 6 ++---- .../unit/services/BatchDeleteService.test.ts | 4 +--- .../core/batch-actions/batch-actions.effects.ts | 1 - .../batch-actions/batch-actions.helpers.spec.ts | 10 ++-------- .../core/batch-actions/batch-actions.helpers.ts | 7 +------ .../batch-actions/batch-actions.http.spec.ts | 2 +- .../batch-actions.integration.spec.ts | 17 +++++------------ .../features/dashboard/batch-delete-ui.spec.ts | 4 ++-- packages/types/src/BatchActions/index.ts | 1 - 13 files changed, 19 insertions(+), 48 deletions(-) diff --git a/packages/backend/src/api/controllers/ExperimentController.ts b/packages/backend/src/api/controllers/ExperimentController.ts index b16a806bb..88ce84989 100644 --- a/packages/backend/src/api/controllers/ExperimentController.ts +++ b/packages/backend/src/api/controllers/ExperimentController.ts @@ -688,7 +688,7 @@ export class ExperimentController { * $ref: '#/definitions/BatchEntityIdsRequest' * responses: * '200': - * description: Inspect phase and per-ID outcomes; rejected means no deletions were performed. + * description: Per-ID deletion outcomes, including failures and items not attempted. * schema: * $ref: '#/definitions/BatchDeleteResult' * '400': diff --git a/packages/backend/src/api/controllers/FeatureFlagController.ts b/packages/backend/src/api/controllers/FeatureFlagController.ts index 7c036bb4e..2cbbc0cd2 100644 --- a/packages/backend/src/api/controllers/FeatureFlagController.ts +++ b/packages/backend/src/api/controllers/FeatureFlagController.ts @@ -180,7 +180,7 @@ export class FeatureFlagsController { * $ref: '#/definitions/BatchEntityIdsRequest' * responses: * '200': - * description: Inspect phase and per-ID outcomes; rejected means no deletions were performed. + * description: Per-ID deletion outcomes, including failures and items not attempted. * schema: * $ref: '#/definitions/BatchDeleteResult' * '400': diff --git a/packages/backend/src/api/controllers/SegmentController.ts b/packages/backend/src/api/controllers/SegmentController.ts index 51b3b887c..919bb2622 100644 --- a/packages/backend/src/api/controllers/SegmentController.ts +++ b/packages/backend/src/api/controllers/SegmentController.ts @@ -70,11 +70,8 @@ interface SegmentPaginationInfo extends PaginationResponse { * enum: [not_found, delete_failed, lock_timeout, external_sync_failed, outcome_unknown, post_delete_failed] * BatchDeleteResult: * type: object - * required: [phase, results] + * required: [results] * properties: - * phase: - * type: string - * enum: [rejected, executed] * results: * type: array * items: @@ -295,7 +292,7 @@ export class SegmentController { * $ref: '#/definitions/BatchEntityIdsRequest' * responses: * '200': - * description: Inspect phase and per-ID outcomes; rejected means no deletions were performed. + * description: Per-ID deletion outcomes, including failures and items not attempted. * schema: * $ref: '#/definitions/BatchDeleteResult' * '400': diff --git a/packages/backend/src/api/services/BatchDeleteService.ts b/packages/backend/src/api/services/BatchDeleteService.ts index 7b7884713..2574f4701 100644 --- a/packages/backend/src/api/services/BatchDeleteService.ts +++ b/packages/backend/src/api/services/BatchDeleteService.ts @@ -37,11 +37,9 @@ export class BatchDeleteService { logger: UpgradeLogger ): Promise { const results: BatchDeleteItemResult[] = []; - let phase: BatchDeleteResult['phase'] = 'rejected'; for (const id of ids) { const result = await this.deleteOne(entity, id, user, logger); results.push(result); - if (result.outcome !== 'not_found') phase = 'executed'; // An already absent target does not prevent independent items from being deleted. if (result.outcome === 'not_found') continue; // A committed item with a post-delete failure must not be retried, but still stops this batch. @@ -55,7 +53,7 @@ export class BatchDeleteService { break; } } - return { phase, results }; + return { results }; } private async deleteOne( diff --git a/packages/backend/test/integration/BatchDelete/index.ts b/packages/backend/test/integration/BatchDelete/index.ts index d07fb1206..6f0791283 100644 --- a/packages/backend/test/integration/BatchDelete/index.ts +++ b/packages/backend/test/integration/BatchDelete/index.ts @@ -190,7 +190,7 @@ export function registerBatchDeleteTests(connections: () => [DataSource, DataSou const lists = await Promise.all(rows.map((row) => ownedList(entity, row.id, child.id))); const ids = rows.map((row) => row.id.toUpperCase()).reverse(); const { body } = await request(app).post(route(entity)).send({ ids }).expect(200); - expect(body).toEqual({ phase: 'executed', results: ids.map((id) => ({ id, outcome: 'deleted' })) }); + expect(body).toEqual({ results: ids.map((id) => ({ id, outcome: 'deleted' })) }); expect(await db.getRepository(model[entity]).countBy({ id: In(ids) })).toBe(0); expect(await db.getRepository(Segment).countBy({ id: In(lists.map((list) => list.id)) })).toBe(0); expect( @@ -272,7 +272,6 @@ export function registerBatchDeleteTests(connections: () => [DataSource, DataSou .send({ ids: [row.id, missing] }) .expect(200); expect(body).toEqual({ - phase: 'executed', results: [ { id: row.id, outcome: 'deleted' }, { id: missing, outcome: 'not_found', reasonCode: DeletionReasonCode.NOT_FOUND }, @@ -301,7 +300,6 @@ export function registerBatchDeleteTests(connections: () => [DataSource, DataSou .send({ ids: rows.map((row) => row.id) }) .expect(200); expect(body).toEqual({ - phase: 'executed', results: [ { id: rows[0].id, outcome: 'deleted' }, { @@ -676,7 +674,7 @@ export function registerBatchDeleteTests(connections: () => [DataSource, DataSou const ids = rows.map((row) => row.id).reverse(); const status = jest.spyOn(Container.get(SegmentService), 'getSegmentStatus'); const { body } = await request(app).post(route(entity)).send({ ids }).expect(200); - expect(body).toEqual({ phase: 'executed', results: ids.map((id) => ({ id, outcome: 'deleted' })) }); + expect(body).toEqual({ results: ids.map((id) => ({ id, outcome: 'deleted' })) }); expect(status).not.toHaveBeenCalled(); expect(await db.getRepository(model[entity]).countBy({ id: In(ids) })).toBe(0); } diff --git a/packages/backend/test/unit/services/BatchDeleteService.test.ts b/packages/backend/test/unit/services/BatchDeleteService.test.ts index 0c44a3dcb..b5d87b61a 100644 --- a/packages/backend/test/unit/services/BatchDeleteService.test.ts +++ b/packages/backend/test/unit/services/BatchDeleteService.test.ts @@ -99,7 +99,7 @@ describe('BatchDeleteService transaction outcomes', () => { test.each(entities)('%s commits sequentially and normalizes existing service responses', async (entity) => { const result = await service.delete(entity, ids, user, logger); - expect(result).toEqual({ phase: 'executed', results: ids.map((id) => ({ id, outcome: 'deleted' })) }); + expect(result).toEqual({ results: ids.map((id) => ({ id, outcome: 'deleted' })) }); expect(mutations).toEqual(ids); for (const runner of runners) { expect(runner.commitTransaction).toHaveBeenCalledTimes(1); @@ -204,7 +204,6 @@ describe('BatchDeleteService transaction outcomes', () => { (runner.manager.getRepository as jest.Mock).mockReturnValue({ findOne: jest.fn().mockResolvedValue(null) }); }; expect(await service.delete('flags', ids, user, logger)).toEqual({ - phase: 'rejected', results: ids.map((id) => ({ id, outcome: 'not_found', reasonCode: DeletionReasonCode.NOT_FOUND })), }); expect(mutations).toEqual([]); @@ -359,7 +358,6 @@ describe('BatchDeleteService transaction outcomes', () => { }); const result = await service.delete('flags', ids, user, logger); - expect(result.phase).toBe('executed'); expect(result.results).toHaveLength(ids.length); expect(result.results[0]).toEqual({ id: ids[0], outcome: 'deleted' }); expect(result.results[1]).toEqual({ diff --git a/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.effects.ts b/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.effects.ts index bb86f5d69..43481b9ea 100644 --- a/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.effects.ts +++ b/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.effects.ts @@ -55,7 +55,6 @@ export function batchDeleteEffect( operationId: snapshot.operationId, // An invalid/empty successful response has no HTTP error for the interceptor to report. result: { - phase: 'executed', results: ids.map((id) => ({ id, outcome: 'unknown', diff --git a/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.helpers.spec.ts b/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.helpers.spec.ts index 93dff9252..6fb6f7d49 100644 --- a/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.helpers.spec.ts +++ b/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.helpers.spec.ts @@ -94,25 +94,19 @@ describe('Root selection rules', () => { it('accepts reordered results but rejects incomplete, duplicate, or unexpected result IDs', () => { const complete: BatchDeleteResult = { - phase: 'executed', results: [ { id: 'b', outcome: 'not_found' }, { id: 'a', outcome: 'deleted' }, ], }; expect(validateBatchResponse(complete, ['a', 'b'])).toBe(complete); - expect(() => - validateBatchResponse({ phase: 'executed', results: [{ id: 'a', outcome: 'deleted' }] }, ['a', 'b']) - ).toThrow(); + expect(() => validateBatchResponse({ results: [{ id: 'a', outcome: 'deleted' }] }, ['a', 'b'])).toThrow(); for (const returnedIds of [ ['a', 'a'], ['a', 'unexpected'], ]) { expect(() => - validateBatchResponse({ phase: 'executed', results: returnedIds.map((id) => ({ id, outcome: 'deleted' })) }, [ - 'a', - 'b', - ]) + validateBatchResponse({ results: returnedIds.map((id) => ({ id, outcome: 'deleted' })) }, ['a', 'b']) ).toThrow(); } }); diff --git a/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.helpers.ts b/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.helpers.ts index b503982e9..e9fc36bd8 100644 --- a/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.helpers.ts +++ b/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.helpers.ts @@ -65,12 +65,7 @@ export const confirmedRemovedIds = (result?: BatchDeleteResult) => export function validateBatchResponse(result: BatchDeleteResult, ids: string[]): BatchDeleteResult { const outcomes = ['deleted', 'not_found', 'failed', 'unknown', 'not_attempted']; - if ( - !result || - !['rejected', 'executed'].includes(result.phase) || - !Array.isArray(result.results) || - result.results.length !== ids.length - ) { + if (!result || !Array.isArray(result.results) || result.results.length !== ids.length) { throw new Error('Incomplete batch deletion response'); } const resultIds = new Set(result.results.map((item) => item.id)); diff --git a/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.http.spec.ts b/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.http.spec.ts index 952303718..1bd64e3cf 100644 --- a/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.http.spec.ts +++ b/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.http.spec.ts @@ -35,7 +35,7 @@ describe('Batch HTTP contracts', () => { expect(deletion.request.method).toBe('POST'); expect(deletion.request.body).toEqual({ ids }); expect(deletion.request.context.get(SKIP_NAVIGATION_CANCEL)).toBe(true); - deletion.flush({ phase: 'executed', results: ids.map((id) => ({ id, outcome: 'deleted' })) }); + deletion.flush({ results: ids.map((id) => ({ id, outcome: 'deleted' })) }); }); it('continues observing a batch response through navigation', () => { diff --git a/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.integration.spec.ts b/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.integration.spec.ts index 0bdf48e4b..ac791b58e 100644 --- a/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.integration.spec.ts +++ b/packages/frontend/projects/upgrade/src/app/core/batch-actions/batch-actions.integration.spec.ts @@ -357,7 +357,6 @@ describe.each(fixtures)('$entity batch store/effects integration', (config) => { store.dispatch(actions.batchDeleteRequested({ snapshot })); response.next({ - phase: 'executed', results: [ { id: rows[0].id, outcome: 'not_found', reasonCode: 'not_found' }, { id: rows[1].id, outcome: 'failed', reasonCode: 'delete_failed' }, @@ -382,7 +381,6 @@ describe.each(fixtures)('$entity batch store/effects integration', (config) => { const snapshot = prepare(); store.dispatch(actions.batchDeleteRequested({ snapshot })); response.next({ - phase: deleted ? 'executed' : 'rejected', results: rows .slice(0, 2) .map(({ id }, index) => @@ -405,7 +403,6 @@ describe.each(fixtures)('$entity batch store/effects integration', (config) => { const snapshot = prepare(); store.dispatch(actions.batchDeleteRequested({ snapshot })); response.next({ - phase: 'executed', results: rows.map(({ id }, index) => ({ id, outcome: index === 0 ? 'failed' : 'not_attempted' })), }); expect(notifications.showError).toHaveBeenCalledWith('1 item could not be deleted. 2 items were not attempted.'); @@ -427,7 +424,6 @@ describe.each(fixtures)('$entity batch store/effects integration', (config) => { expect(data.batchDelete).toHaveBeenCalledTimes(1); expect(data.batchDelete).toHaveBeenCalledWith(rows.map((row) => row.id)); response.next({ - phase: 'executed', results: rows.map((row, index) => ({ id: row.id, outcome: index === 0 ? 'deleted' : index === 1 ? 'failed' : 'not_attempted', @@ -527,7 +523,7 @@ describe.each(fixtures)('$entity batch store/effects integration', (config) => { const duringDeletion = new Subject(); data[config.fetchMethod].mockReturnValueOnce(duringDeletion); store.dispatch(config.fetch({ fromStarting: true })); - response.next({ phase: 'executed', results: rows.slice(0, count).map(({ id }) => ({ id, outcome: 'deleted' })) }); + response.next({ results: rows.slice(0, count).map(({ id }) => ({ id, outcome: 'deleted' })) }); pending.next(page()); duringDeletion.next(page()); const oldSuccess = events.find( @@ -621,7 +617,7 @@ describe.each(fixtures)('$entity batch store/effects integration', (config) => { ); store.dispatch(detailLoaded); expect(detailsError()).toBeNull(); - response.next({ phase: 'executed', results: [{ id: viewed.id, outcome: 'deleted' }] }); + response.next({ results: [{ id: viewed.id, outcome: 'deleted' }] }); const notFound = { entityId: viewed.id, errorType: PAGE_ERROR_TYPE.NOT_FOUND }; expect(detailsError()).toEqual(notFound); store.dispatch(detailLoaded); @@ -637,7 +633,7 @@ describe.each(fixtures)('$entity batch store/effects integration', (config) => { store.dispatch(actions.batchDeleteRequested({ snapshot })); const pendingRefresh = new Subject(); data[config.fetchMethod].mockReturnValue(pendingRefresh); - const result = { phase: 'executed', results: [{ id: rows[0].id, outcome: 'deleted' }] }; + const result = { results: [{ id: rows[0].id, outcome: 'deleted' }] }; if (pending === 'refresh') { response.next(result); expect(pendingRefresh.observed).toBe(true); @@ -698,7 +694,6 @@ describe.each(fixtures)('$entity batch store/effects integration', (config) => { data[config.fetchMethod].mockReturnValue(throwError(() => new Error('refresh unavailable'))); store.dispatch(actions.batchDeleteRequested({ snapshot })); response.next({ - phase: 'executed', results: [{ id: rows[0].id, outcome: 'deleted', reasonCode: 'post_delete_failed' }], }); const noun = { experiments: 'experiment', flags: 'feature flag', segments: 'segment' }[config.entity]; @@ -732,7 +727,6 @@ describe.each(fixtures)('$entity batch store/effects integration', (config) => { data[config.fetchMethod].mockReturnValueOnce(of(page([rows[2]]))); store.dispatch(actions.batchDeleteRequested({ snapshot })); response.next({ - phase: 'executed', results: [ { id: rows[0].id, outcome: 'deleted' }, { id: rows[1].id, outcome: 'unknown' }, @@ -758,7 +752,7 @@ describe.each(fixtures)('$entity batch store/effects integration', (config) => { selectRows(); const snapshot = prepare(); store.dispatch(actions.batchDeleteRequested({ snapshot })); - response.next({ phase: 'executed', results: [] }); + response.next({ results: [] }); expect(batch().operation.result.results.every((result) => result.outcome === 'unknown')).toBe(true); expect(Object.keys(batch().selectedById)).toHaveLength(3); expect(batch().operation.status).toBe('complete'); @@ -775,7 +769,6 @@ describe.each(fixtures)('$entity batch store/effects integration', (config) => { const first = prepare(); store.dispatch(actions.batchDeleteRequested({ snapshot: first })); response.next({ - phase: 'executed', results: rows.map(({ id }, index) => ({ id, outcome: index === 0 ? 'deleted' : 'not_attempted' })), }); response.complete(); @@ -794,7 +787,7 @@ describe.each(fixtures)('$entity batch store/effects integration', (config) => { store.dispatch(actions.batchDeleteRequested({ snapshot })); store.dispatch(actionLogoutStart()); store.dispatch(actionSetUserInfo({ user: { email: 'next@example.com', role: UserRole.ADMIN } })); - response.next({ phase: 'executed', results: rows.map(({ id }) => ({ id, outcome: 'deleted' })) }); + response.next({ results: rows.map(({ id }) => ({ id, outcome: 'deleted' })) }); expect(batch().operation).toBeNull(); expect(Object.keys(batch().selectedById)).toHaveLength(0); expect(batch().removedIds).toHaveLength(0); diff --git a/packages/frontend/projects/upgrade/src/app/features/dashboard/batch-delete-ui.spec.ts b/packages/frontend/projects/upgrade/src/app/features/dashboard/batch-delete-ui.spec.ts index a81a528d1..a6ccb38ee 100644 --- a/packages/frontend/projects/upgrade/src/app/features/dashboard/batch-delete-ui.spec.ts +++ b/packages/frontend/projects/upgrade/src/app/features/dashboard/batch-delete-ui.spec.ts @@ -372,7 +372,7 @@ describe.each(cases)('$entity root batch UI', (config) => { store.dispatch( actions.batchDeleteCompleted({ operationId: snapshot.operationId, - result: { phase: 'executed', results: [{ id: rows[0].id, outcome: 'deleted' }] }, + result: { results: [{ id: rows[0].id, outcome: 'deleted' }] }, }) ); expect(batch().operation.status).toBe('complete'); @@ -480,7 +480,7 @@ describe.each(cases)('$entity root batch UI', (config) => { store.dispatch( actions.batchDeleteCompleted({ operationId: 'pending-delete', - result: { phase: 'executed', results: [{ id: rows[0].id, outcome: 'deleted' }] }, + result: { results: [{ id: rows[0].id, outcome: 'deleted' }] }, }) ); if (first === 'list') listLoading$.next(false); diff --git a/packages/types/src/BatchActions/index.ts b/packages/types/src/BatchActions/index.ts index f98ae8112..7bae90b14 100644 --- a/packages/types/src/BatchActions/index.ts +++ b/packages/types/src/BatchActions/index.ts @@ -22,6 +22,5 @@ export interface BatchDeleteItemResult { } export interface BatchDeleteResult { - phase: 'rejected' | 'executed'; results: BatchDeleteItemResult[]; }