NIFI-15028 - Ensure parameter context edit async polling stops after cancelling the update (#10357)

diff --git a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/service/parameter-helper.service.ts b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/service/parameter-helper.service.ts
index 14ef171..156cc82 100644
--- a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/service/parameter-helper.service.ts
+++ b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/service/parameter-helper.service.ts
@@ -90,10 +90,11 @@
                     convertToParameterDialogReference.componentInstance.saving$ =
                         this.store.select(selectParameterSaving);
 
-                    convertToParameterDialogReference.componentInstance.exit.pipe(
-                        takeUntil(convertToParameterDialogReference.afterClosed()),
-                        tap(() => ParameterActions.stopPollingParameterContextUpdateRequest())
-                    );
+                    convertToParameterDialogReference.componentInstance.exit
+                        .pipe(takeUntil(convertToParameterDialogReference.afterClosed()))
+                        .subscribe(() =>
+                            this.store.dispatch(ParameterActions.stopPollingParameterContextUpdateRequest())
+                        );
 
                     return convertToParameterDialogReference.componentInstance.editParameter.pipe(
                         takeUntil(convertToParameterDialogReference.afterClosed()),
diff --git a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/parameter-contexts/state/parameter-context-listing/index.ts b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/parameter-contexts/state/parameter-context-listing/index.ts
index 76cca5f..fb0bcdc 100644
--- a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/parameter-contexts/state/parameter-context-listing/index.ts
+++ b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/parameter-contexts/state/parameter-context-listing/index.ts
@@ -57,5 +57,6 @@
     updateRequestEntity: ParameterContextUpdateRequestEntity | null;
     saving: boolean;
     loadedTimestamp: string;
+    deleteUpdateRequestInitiated: boolean;
     status: 'pending' | 'loading' | 'success';
 }
diff --git a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/parameter-contexts/state/parameter-context-listing/parameter-context-listing.effects.spec.ts b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/parameter-contexts/state/parameter-context-listing/parameter-context-listing.effects.spec.ts
new file mode 100644
index 0000000..698fd46
--- /dev/null
+++ b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/parameter-contexts/state/parameter-context-listing/parameter-context-listing.effects.spec.ts
@@ -0,0 +1,191 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { TestBed } from '@angular/core/testing';
+import { provideMockActions } from '@ngrx/effects/testing';
+import { Action } from '@ngrx/store';
+import { ReplaySubject, of } from 'rxjs';
+import { provideMockStore } from '@ngrx/store/testing';
+import { MatDialog } from '@angular/material/dialog';
+import { Router } from '@angular/router';
+
+import { ParameterContextListingEffects } from './parameter-context-listing.effects';
+import * as ParameterContextListingActions from './parameter-context-listing.actions';
+import { ParameterContextService } from '../../service/parameter-contexts.service';
+import { ErrorHelper } from '../../../../service/error-helper.service';
+import { Storage, NiFiCommon } from '@nifi/shared';
+import { initialState } from './parameter-context-listing.reducer';
+import { ParameterContextUpdateRequest, ParameterContextUpdateRequestEntity } from '../../../../state/shared';
+
+describe('ParameterContextListingEffects', () => {
+    interface SetupOptions {
+        updateRequest?: ParameterContextUpdateRequestEntity | null;
+        deleteUpdateRequestInitiated?: boolean;
+    }
+
+    let action$: ReplaySubject<Action>;
+
+    function createMockUpdateRequest(): ParameterContextUpdateRequest {
+        return {
+            requestId: 'test-request-id',
+            uri: 'http://localhost:8080/test-uri',
+            lastUpdated: '2023-01-01T00:00:00Z',
+            complete: false,
+            failureReason: undefined,
+            percentComponent: 50,
+            state: 'In Progress',
+            updateSteps: [],
+            parameterContext: {} as any,
+            referencingComponents: []
+        };
+    }
+
+    async function setup({ updateRequest = null, deleteUpdateRequestInitiated = false }: SetupOptions = {}) {
+        await TestBed.configureTestingModule({
+            providers: [
+                ParameterContextListingEffects,
+                provideMockActions(() => action$),
+                provideMockStore({
+                    initialState: {
+                        parameterContextListing: {
+                            ...initialState,
+                            updateRequestEntity: updateRequest,
+                            deleteUpdateRequestInitiated
+                        }
+                    }
+                }),
+                { provide: ParameterContextService, useValue: { deleteParameterContextUpdate: jest.fn() } },
+                { provide: MatDialog, useValue: { open: jest.fn() } },
+                { provide: Router, useValue: { navigate: jest.fn() } },
+                { provide: ErrorHelper, useValue: { getErrorString: jest.fn() } },
+                { provide: Storage, useValue: { setItem: jest.fn() } },
+                { provide: NiFiCommon, useValue: { stripProtocol: jest.fn() } }
+            ]
+        }).compileComponents();
+
+        const effects = TestBed.inject(ParameterContextListingEffects);
+        const parameterContextService = TestBed.inject(ParameterContextService) as jest.Mocked<ParameterContextService>;
+        action$ = new ReplaySubject<Action>();
+
+        return { effects, parameterContextService };
+    }
+
+    beforeEach(() => {
+        jest.clearAllMocks();
+    });
+
+    it('should create', async () => {
+        const { effects } = await setup();
+        expect(effects).toBeTruthy();
+    });
+
+    describe('stopPollingParameterContextUpdateRequest$', () => {
+        it('should dispatch deleteParameterContextUpdateRequest when triggered', async () => {
+            const { effects } = await setup();
+
+            action$.next(ParameterContextListingActions.stopPollingParameterContextUpdateRequest());
+
+            effects.stopPollingParameterContextUpdateRequest$.subscribe((action) => {
+                expect(action).toEqual(ParameterContextListingActions.deleteParameterContextUpdateRequest());
+            });
+        });
+    });
+
+    describe('deleteParameterContextUpdateRequest$', () => {
+        it('should call service when deleteUpdateRequestInitiated is false', async () => {
+            const mockUpdateRequest = createMockUpdateRequest();
+            const mockResponse = { request: mockUpdateRequest };
+
+            const { effects, parameterContextService } = await setup({
+                updateRequest: { request: mockUpdateRequest, parameterContextRevision: { version: 1 } },
+                deleteUpdateRequestInitiated: false
+            });
+
+            parameterContextService.deleteParameterContextUpdate.mockReturnValue(of(mockResponse));
+
+            action$.next(ParameterContextListingActions.deleteParameterContextUpdateRequest());
+
+            effects.deleteParameterContextUpdateRequest$.subscribe(() => {
+                expect(parameterContextService.deleteParameterContextUpdate).toHaveBeenCalledWith(mockUpdateRequest);
+            });
+        });
+
+        it('should call service when deleteUpdateRequestInitiated is true', async () => {
+            const mockUpdateRequest = createMockUpdateRequest();
+            const mockResponse = { request: mockUpdateRequest };
+
+            const { effects, parameterContextService } = await setup({
+                updateRequest: { request: mockUpdateRequest, parameterContextRevision: { version: 1 } },
+                deleteUpdateRequestInitiated: true
+            });
+
+            parameterContextService.deleteParameterContextUpdate.mockReturnValue(of(mockResponse));
+
+            action$.next(ParameterContextListingActions.deleteParameterContextUpdateRequest());
+
+            effects.deleteParameterContextUpdateRequest$.subscribe(() => {
+                expect(parameterContextService.deleteParameterContextUpdate).toHaveBeenCalledWith(mockUpdateRequest);
+            });
+        });
+    });
+
+    describe('pollParameterContextUpdateRequestSuccess$', () => {
+        it('should dispatch stopPolling when request is complete', async () => {
+            const completeUpdateRequest = createMockUpdateRequest();
+            completeUpdateRequest.complete = true;
+
+            const response = {
+                requestEntity: {
+                    request: completeUpdateRequest,
+                    parameterContextRevision: { version: 1 }
+                }
+            };
+
+            const { effects } = await setup();
+
+            action$.next(ParameterContextListingActions.pollParameterContextUpdateRequestSuccess({ response }));
+
+            effects.pollParameterContextUpdateRequestSuccess$.subscribe((action) => {
+                expect(action).toEqual(ParameterContextListingActions.stopPollingParameterContextUpdateRequest());
+            });
+        });
+
+        it('should not dispatch when request is incomplete', async () => {
+            const incompleteUpdateRequest = createMockUpdateRequest();
+            incompleteUpdateRequest.complete = false;
+
+            const response = {
+                requestEntity: {
+                    request: incompleteUpdateRequest,
+                    parameterContextRevision: { version: 1 }
+                }
+            };
+
+            const { effects } = await setup();
+
+            const emissions: any[] = [];
+            effects.pollParameterContextUpdateRequestSuccess$.subscribe((action) => {
+                emissions.push(action);
+            });
+
+            action$.next(ParameterContextListingActions.pollParameterContextUpdateRequestSuccess({ response }));
+
+            // Since the effect is synchronous with filter, we can check immediately
+            expect(emissions).toEqual([]);
+        });
+    });
+});
diff --git a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/parameter-contexts/state/parameter-context-listing/parameter-context-listing.effects.ts b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/parameter-contexts/state/parameter-context-listing/parameter-context-listing.effects.ts
index 5f4542c..1ca35bf 100644
--- a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/parameter-contexts/state/parameter-context-listing/parameter-context-listing.effects.ts
+++ b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/parameter-contexts/state/parameter-context-listing/parameter-context-listing.effects.ts
@@ -44,7 +44,8 @@
     selectParameterContexts,
     selectParameterContextStatus,
     selectSaving,
-    selectUpdateRequest
+    selectUpdateRequest,
+    selectDeleteUpdateRequestInitiated
 } from './parameter-context-listing.selectors';
 import { EditParameterRequest, EditParameterResponse, ParameterContextUpdateRequest } from '../../../../state/shared';
 import { EditParameterDialog } from '../../../../ui/common/edit-parameter-dialog/edit-parameter-dialog.component';
@@ -383,6 +384,14 @@
                             );
                         });
 
+                    editDialogReference.componentInstance.cancelUpdateRequest
+                        .pipe(takeUntil(editDialogReference.afterClosed()))
+                        .subscribe(() => {
+                            this.store.dispatch(
+                                ParameterContextListingActions.stopPollingParameterContextUpdateRequest()
+                            );
+                        });
+
                     editDialogReference.afterClosed().subscribe((response) => {
                         if (response != 'ROUTED') {
                             this.store.dispatch(
@@ -504,6 +513,8 @@
     stopPollingParameterContextUpdateRequest$ = createEffect(() =>
         this.actions$.pipe(
             ofType(ParameterContextListingActions.stopPollingParameterContextUpdateRequest),
+            concatLatestFrom(() => this.store.select(selectDeleteUpdateRequestInitiated)),
+            filter(([, deleteUpdateRequestInitiated]) => !deleteUpdateRequestInitiated),
             switchMap(() => of(ParameterContextListingActions.deleteParameterContextUpdateRequest()))
         )
     );
diff --git a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/parameter-contexts/state/parameter-context-listing/parameter-context-listing.reducer.ts b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/parameter-contexts/state/parameter-context-listing/parameter-context-listing.reducer.ts
index 4677631..21d9e67 100644
--- a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/parameter-contexts/state/parameter-context-listing/parameter-context-listing.reducer.ts
+++ b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/parameter-contexts/state/parameter-context-listing/parameter-context-listing.reducer.ts
@@ -30,7 +30,8 @@
     pollParameterContextUpdateRequestSuccess,
     submitParameterContextUpdateRequest,
     submitParameterContextUpdateRequestSuccess,
-    deleteParameterContextUpdateRequestSuccess
+    deleteParameterContextUpdateRequestSuccess,
+    deleteParameterContextUpdateRequest
 } from './parameter-context-listing.actions';
 import { ParameterContextUpdateRequestEntity } from '../../../../state/shared';
 import { Revision } from '@nifi/shared';
@@ -40,6 +41,7 @@
     updateRequestEntity: null,
     saving: false,
     loadedTimestamp: '',
+    deleteUpdateRequestInitiated: false,
     status: 'pending'
 };
 
@@ -83,6 +85,10 @@
             updateRequestEntity: response.requestEntity
         })
     ),
+    on(deleteParameterContextUpdateRequest, (state) => ({
+        ...state,
+        deleteUpdateRequestInitiated: true
+    })),
     on(deleteParameterContextUpdateRequestSuccess, (state) => ({
         ...state,
         saving: false
@@ -93,25 +99,30 @@
 
             if (updateRequestEntity) {
                 const revision: Revision = updateRequestEntity.parameterContextRevision;
-                const parameterContext: any = updateRequestEntity.request.parameterContext;
 
-                const componentIndex: number = draftState.parameterContexts.findIndex(
-                    (f: any) => parameterContext.id === f.id
-                );
-                if (componentIndex > -1) {
-                    draftState.parameterContexts[componentIndex] = {
-                        ...draftState.parameterContexts[componentIndex],
-                        revision: {
-                            ...revision
-                        },
-                        component: {
-                            ...parameterContext
-                        }
-                    };
+                // update state if completed, otherwise there won't be a parameter context on the request
+                if (updateRequestEntity.request.complete) {
+                    const parameterContext: any = updateRequestEntity.request.parameterContext;
+
+                    const componentIndex: number = draftState.parameterContexts.findIndex(
+                        (f: any) => parameterContext.id === f.id
+                    );
+                    if (componentIndex > -1) {
+                        draftState.parameterContexts[componentIndex] = {
+                            ...draftState.parameterContexts[componentIndex],
+                            revision: {
+                                ...revision
+                            },
+                            component: {
+                                ...parameterContext
+                            }
+                        };
+                    }
                 }
 
                 draftState.updateRequestEntity = null;
                 draftState.saving = false;
+                draftState.deleteUpdateRequestInitiated = false;
             }
         });
     }),
diff --git a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/parameter-contexts/state/parameter-context-listing/parameter-context-listing.selectors.ts b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/parameter-contexts/state/parameter-context-listing/parameter-context-listing.selectors.ts
index 802a7f5..755dafe 100644
--- a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/parameter-contexts/state/parameter-context-listing/parameter-context-listing.selectors.ts
+++ b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/parameter-contexts/state/parameter-context-listing/parameter-context-listing.selectors.ts
@@ -64,3 +64,8 @@
     createSelector(selectParameterContexts, (parameterContexts: ParameterContextEntity[]) =>
         parameterContexts.find((entity) => id == entity.id)
     );
+
+export const selectDeleteUpdateRequestInitiated = createSelector(
+    selectParameterContextListingState,
+    (state: ParameterContextListingState) => state.deleteUpdateRequestInitiated
+);
diff --git a/nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/parameter-context/edit-parameter-context/edit-parameter-context.component.html b/nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/parameter-context/edit-parameter-context/edit-parameter-context.component.html
index 421cd7a..9ef15ca 100644
--- a/nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/parameter-context/edit-parameter-context/edit-parameter-context.component.html
+++ b/nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/parameter-context/edit-parameter-context/edit-parameter-context.component.html
@@ -179,7 +179,7 @@
             @if (requestEntity.request.complete) {
                 <button mat-flat-button mat-dialog-close>Close</button>
             } @else {
-                <button mat-button mat-dialog-close>Cancel</button>
+                <button mat-button mat-dialog-close (click)="cancelUpdateRequest.emit()">Cancel</button>
             }
         </mat-dialog-actions>
     } @else {
@@ -188,7 +188,7 @@
                 @if (readonly) {
                     <button mat-flat-button mat-dialog-close>Close</button>
                 } @else {
-                    <button mat-button mat-dialog-close>Cancel</button>
+                    <button mat-button mat-dialog-close (click)="cancelUpdateRequest.emit()">Cancel</button>
                     <button
                         [disabled]="
                             !editParameterContextForm.dirty ||
diff --git a/nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/parameter-context/edit-parameter-context/edit-parameter-context.component.spec.ts b/nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/parameter-context/edit-parameter-context/edit-parameter-context.component.spec.ts
index cd44eb4..8f99f51 100644
--- a/nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/parameter-context/edit-parameter-context/edit-parameter-context.component.spec.ts
+++ b/nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/parameter-context/edit-parameter-context/edit-parameter-context.component.spec.ts
@@ -16,6 +16,7 @@
  */
 
 import { ComponentFixture, TestBed } from '@angular/core/testing';
+import { EventEmitter } from '@angular/core';
 
 import { EditParameterContext } from './edit-parameter-context.component';
 import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
@@ -260,6 +261,19 @@
         expect(component).toBeTruthy();
     });
 
+    it('should have cancelUpdateRequest EventEmitter', () => {
+        expect(component.cancelUpdateRequest).toBeDefined();
+        expect(component.cancelUpdateRequest).toBeInstanceOf(EventEmitter);
+    });
+
+    it('should emit cancelUpdateRequest when called', () => {
+        const spy = jest.spyOn(component.cancelUpdateRequest, 'emit');
+
+        component.cancelUpdateRequest.emit();
+
+        expect(spy).toHaveBeenCalledTimes(1);
+    });
+
     describe('inheritsParameters', () => {
         it('should return true if parameters are inherited', () => {
             const parameters: ParameterEntity[] = [
diff --git a/nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/parameter-context/edit-parameter-context/edit-parameter-context.component.ts b/nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/parameter-context/edit-parameter-context/edit-parameter-context.component.ts
index 718b9a8..4ed38d9 100644
--- a/nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/parameter-context/edit-parameter-context/edit-parameter-context.component.ts
+++ b/nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/parameter-context/edit-parameter-context/edit-parameter-context.component.ts
@@ -88,6 +88,7 @@
 
     @Output() addParameterContext: EventEmitter<any> = new EventEmitter<any>();
     @Output() editParameterContext: EventEmitter<any> = new EventEmitter<any>();
+    @Output() cancelUpdateRequest: EventEmitter<any> = new EventEmitter<any>();
 
     editParameterContextForm: FormGroup;
     readonly: boolean;