This is an automated email from the ASF dual-hosted git repository.
mcgilman pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/nifi.git
The following commit(s) were added to refs/heads/main by this push:
new d0a06ddd87f NIFI-16217 - Fix: Unsaved Parameter Context changes
silently lost when navigating to an inherited Parameter Context (#11559)
d0a06ddd87f is described below
commit d0a06ddd87f615dec76ae9c6f678016d8185431b
Author: Rob Fellows <[email protected]>
AuthorDate: Fri Aug 21 13:03:24 2026 -0400
NIFI-16217 - Fix: Unsaved Parameter Context changes silently lost when
navigating to an inherited Parameter Context (#11559)
* NIFI-16217 - Fix: Unsaved Parameter Context changes silently lost when
navigating to an inherited Parameter Context
* address review feedback
---
.../state/parameter-context-listing/index.ts | 11 +-
.../parameter-context-listing.actions.ts | 10 +-
.../parameter-context-listing.effects.spec.ts | 401 ++++++++++++++++++++-
.../parameter-context-listing.effects.ts | 143 +++++++-
.../parameter-context-listing.reducer.spec.ts | 151 ++++++++
.../parameter-context-listing.reducer.ts | 17 +-
.../parameter-context-listing.selectors.ts | 20 +
.../parameter-context-listing.component.spec.ts | 114 +++++-
.../parameter-context-listing.component.ts | 22 +-
.../parameter-table/parameter-table.component.html | 3 +-
.../parameter-table.component.spec.ts | 161 +++++++++
.../parameter-table/parameter-table.component.ts | 52 ++-
.../apps/nifi/src/app/state/shared/index.ts | 7 +
.../edit-parameter-context.component.html | 40 +-
.../edit-parameter-context.component.spec.ts | 143 +++++++-
.../edit-parameter-context.component.ts | 27 +-
.../src/app/ui/common/parameter-context/index.ts | 9 +-
...parameter-context-changes-dialog.component.html | 82 +++++
...ameter-context-changes-dialog.component.spec.ts | 110 ++++++
...e-parameter-context-changes-dialog.component.ts | 46 +++
nifi-frontend/src/main/frontend/nx.json | 3 +-
21 files changed, 1504 insertions(+), 68 deletions(-)
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 ec65dd7b25d..4a3761b0736 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
@@ -15,7 +15,11 @@
* limitations under the License.
*/
-import { ParameterContextEntity, ParameterContextUpdateRequestEntity } from
'../../../../state/shared';
+import {
+ ParameterContextEntity,
+ ParameterContextUpdateRequestEntity,
+ PostUpdateNavigationState
+} from '../../../../state/shared';
export const parameterContextListingFeatureKey = 'parameterContextListing';
@@ -34,10 +38,12 @@ export interface CreateParameterContextSuccess {
export interface GetEffectiveParameterContext {
id: string;
+ highlightedParameterName?: string;
}
export interface EditParameterContextRequest {
parameterContext?: ParameterContextEntity;
+ highlightedParameterName?: string;
}
export interface DeleteParameterContextRequest {
@@ -56,6 +62,9 @@ export interface ParameterContextListingState {
parameterContexts: ParameterContextEntity[];
updateRequestEntity: ParameterContextUpdateRequestEntity | null;
updateRequestParameterContextId: string | null;
+ postUpdateNavigation: string[] | null;
+ postUpdateNavigationBoundary: string[] | null;
+ postUpdateNavigationState: PostUpdateNavigationState | null;
saving: boolean;
loadedTimestamp: string;
deleteUpdateRequestInitiated: boolean;
diff --git
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/parameter-contexts/state/parameter-context-listing/parameter-context-listing.actions.ts
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/parameter-contexts/state/parameter-context-listing/parameter-context-listing.actions.ts
index 11b50535e1e..c2117ddf6bb 100644
---
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/parameter-contexts/state/parameter-context-listing/parameter-context-listing.actions.ts
+++
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/parameter-contexts/state/parameter-context-listing/parameter-context-listing.actions.ts
@@ -16,14 +16,18 @@
*/
import { createAction, props } from '@ngrx/store';
-import { LoadParameterContextsResponse, SelectParameterContextRequest,
GetEffectiveParameterContext } from './index';
+import {
+ LoadParameterContextsResponse,
+ SelectParameterContextRequest,
+ GetEffectiveParameterContext,
+ EditParameterContextRequest
+} from './index';
import { PollParameterContextUpdateSuccess, SubmitParameterContextUpdate }
from '../../../../state/shared';
import {
CreateParameterContextRequest,
CreateParameterContextSuccess,
DeleteParameterContextRequest,
- DeleteParameterContextSuccess,
- EditParameterContextRequest
+ DeleteParameterContextSuccess
} from '../../../../ui/common/parameter-context';
export const loadParameterContexts = createAction('[Parameter Context Listing]
Load Parameter Contexts');
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
index 544b5ce1630..b689ee21bd6 100644
---
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
@@ -15,14 +15,17 @@
* limitations under the License.
*/
+import { EventEmitter } from '@angular/core';
import { TestBed } from '@angular/core/testing';
-import { provideMockActions } from '@ngrx/effects/testing';
-import { Action } from '@ngrx/store';
-import { ReplaySubject, of, throwError } from 'rxjs';
-import { take } from 'rxjs/operators';
-import { provideMockStore } from '@ngrx/store/testing';
+import { FormControl, FormGroup, Validators } from '@angular/forms';
import { MatDialog } from '@angular/material/dialog';
import { Router } from '@angular/router';
+import { Action } from '@ngrx/store';
+import { MockStore, provideMockStore } from '@ngrx/store/testing';
+import { provideMockActions } from '@ngrx/effects/testing';
+import { ReplaySubject, Subject, of, throwError } from 'rxjs';
+import { take } from 'rxjs/operators';
+import type { Mock, Mocked } from 'vitest';
import { ParameterContextListingEffects } from
'./parameter-context-listing.effects';
import * as ParameterContextListingActions from
'./parameter-context-listing.actions';
@@ -30,8 +33,13 @@ import { ParameterContextService } from
'../../service/parameter-contexts.servic
import { ErrorHelper } from '../../../../service/error-helper.service';
import { Storage } from '@nifi/shared';
import { initialState } from './parameter-context-listing.reducer';
+import { parameterContextsFeatureKey } from '../';
+import { parameterContextListingFeatureKey } from './index';
import { ParameterContextUpdateRequest, ParameterContextUpdateRequestEntity }
from '../../../../state/shared';
import { HttpErrorResponse } from '@angular/common/http';
+import { EditParameterContext } from
'../../../../ui/common/parameter-context/edit-parameter-context/edit-parameter-context.component';
+import { SaveParameterContextChangesDialog } from
'../../../../ui/common/parameter-context/save-parameter-context-changes-dialog/save-parameter-context-changes-dialog.component';
+import { EditParameterContextUpdate } from
'../../../../ui/common/parameter-context';
describe('ParameterContextListingEffects', () => {
interface SetupOptions {
@@ -39,6 +47,11 @@ describe('ParameterContextListingEffects', () => {
updateRequestParameterContextId?: string | null;
deleteUpdateRequestInitiated?: boolean;
listStateOverride?: any;
+ postUpdateNavigation?: string[] | null;
+ postUpdateNavigationBoundary?: string[] | null;
+ postUpdateNavigationState?: { highlightedParameterName?: string } |
null;
+ formDirty?: boolean;
+ formValid?: boolean;
}
let action$: ReplaySubject<Action>;
@@ -58,25 +71,99 @@ describe('ParameterContextListingEffects', () => {
};
}
+ function createParameterContext(id = 'pc-source') {
+ return {
+ id,
+ uri: `/parameter-contexts/${id}`,
+ revision: { version: 1 },
+ permissions: { canRead: true, canWrite: true },
+ component: {
+ id,
+ name: 'Source Context',
+ description: '',
+ parameters: [],
+ boundProcessGroups: [],
+ inheritedParameterContexts: []
+ }
+ };
+ }
+
async function setup({
updateRequest = null,
updateRequestParameterContextId = null,
deleteUpdateRequestInitiated = false,
- listStateOverride
+ listStateOverride,
+ postUpdateNavigation = null,
+ postUpdateNavigationBoundary = null,
+ postUpdateNavigationState = null,
+ formDirty = false,
+ formValid = true
}: SetupOptions = {}) {
+ const editParameterContext = new
EventEmitter<EditParameterContextUpdate>();
+ const continuePostUpdateNavigation = new EventEmitter<void>();
+ const cancelUpdateRequest = new EventEmitter<void>();
+ const editDialogAfterClosed$ = new Subject<string | undefined>();
+ const saveChangesAfterClosed$ = new Subject<void>();
+ const save = new EventEmitter<void>();
+ const discard = new EventEmitter<void>();
+ const submitForm = vi.fn();
+
+ const editParameterContextForm = new FormGroup({
+ name: new FormControl(formValid ? 'Source Context' : '',
Validators.required),
+ description: new FormControl(''),
+ parameters: new FormControl([]),
+ inheritedParameterContexts: new FormControl([])
+ });
+ if (formDirty) {
+ editParameterContextForm.markAsDirty();
+ }
+
+ const editDialogRef = {
+ componentInstance: {
+ updateRequest: undefined as unknown,
+ availableParameterContexts$: undefined as unknown,
+ saving$: undefined as unknown,
+ hasPendingPostUpdateNavigation$: undefined as unknown,
+ goToParameter: undefined as unknown,
+ createNewParameter: undefined as unknown,
+ editParameter: undefined as unknown,
+ editParameterContext,
+ continuePostUpdateNavigation,
+ cancelUpdateRequest,
+ editParameterContextForm,
+ submitForm
+ },
+ afterClosed: () => editDialogAfterClosed$.asObservable()
+ };
+
+ const saveChangesDialogRef = {
+ componentInstance: { save, discard },
+ afterClosed: () => saveChangesAfterClosed$.asObservable()
+ };
+
+ const dialogOpen = vi.fn((component: unknown) => {
+ if (component === SaveParameterContextChangesDialog) {
+ return saveChangesDialogRef;
+ }
+ return editDialogRef;
+ });
+
await TestBed.configureTestingModule({
providers: [
ParameterContextListingEffects,
provideMockActions(() => action$),
provideMockStore({
initialState: {
- parameterContexts: {
- parameterContextListing: {
+ [parameterContextsFeatureKey]: {
+ [parameterContextListingFeatureKey]: {
...initialState,
...listStateOverride,
updateRequestEntity: updateRequest,
updateRequestParameterContextId,
- deleteUpdateRequestInitiated
+ deleteUpdateRequestInitiated,
+ postUpdateNavigation,
+ postUpdateNavigationBoundary,
+ postUpdateNavigationState
}
}
}
@@ -89,8 +176,8 @@ describe('ParameterContextListingEffects', () => {
getParameterContexts: vi.fn()
}
},
- { provide: MatDialog, useValue: { open: vi.fn() } },
- { provide: Router, useValue: { navigate: vi.fn() } },
+ { provide: MatDialog, useValue: { open: dialogOpen } },
+ { provide: Router, useValue: { navigate: vi.fn(() =>
Promise.resolve(true)) } },
{
provide: ErrorHelper,
useValue: { getErrorString: vi.fn(), handleLoadingError:
vi.fn(), fullScreenError: vi.fn() }
@@ -100,11 +187,47 @@ describe('ParameterContextListingEffects', () => {
}).compileComponents();
const effects = TestBed.inject(ParameterContextListingEffects);
- const parameterContextService =
TestBed.inject(ParameterContextService) as vi.Mocked<ParameterContextService>;
+ const parameterContextService =
TestBed.inject(ParameterContextService) as Mocked<ParameterContextService>;
+ const dialog = TestBed.inject(MatDialog) as Mocked<MatDialog>;
+ const router = TestBed.inject(Router) as Mocked<Router>;
+ const store = TestBed.inject(MockStore);
+ const dispatchSpy = vi.spyOn(store, 'dispatch');
action$ = new ReplaySubject<Action>();
const errorHelper = TestBed.inject(ErrorHelper);
- return { effects, parameterContextService, errorHelper };
+ return {
+ effects,
+ parameterContextService,
+ errorHelper,
+ dialog,
+ dialogOpen,
+ router,
+ store,
+ dispatchSpy,
+ editDialogRef,
+ saveChangesDialogRef,
+ save,
+ discard,
+ editParameterContext,
+ continuePostUpdateNavigation,
+ submitForm,
+ editDialogAfterClosed$,
+ saveChangesAfterClosed$
+ };
+ }
+
+ async function openEditDialog(
+ effects: ParameterContextListingEffects,
+ parameterContext = createParameterContext()
+ ) {
+ const subscription = effects.openParameterContextDialog$.subscribe();
+ action$.next(
+ ParameterContextListingActions.openParameterContextDialog({
+ request: { parameterContext }
+ })
+ );
+ await Promise.resolve();
+ return subscription;
}
beforeEach(() => {
@@ -115,6 +238,7 @@ describe('ParameterContextListingEffects', () => {
if (action$) {
action$.complete();
}
+ TestBed.resetTestingModule();
});
it('should create', async () => {
@@ -128,7 +252,7 @@ describe('ParameterContextListingEffects', () => {
action$.next(ParameterContextListingActions.loadParameterContexts());
- (parameterContextService.getParameterContexts as
vi.Mock).mockReturnValueOnce(
+ (parameterContextService.getParameterContexts as
Mock).mockReturnValueOnce(
of({ parameterContexts: [], currentTime: 't' })
);
@@ -149,7 +273,7 @@ describe('ParameterContextListingEffects', () => {
action$.next(ParameterContextListingActions.loadParameterContexts());
const error = new HttpErrorResponse({ status: 500 });
- (parameterContextService.getParameterContexts as
vi.Mock).mockImplementationOnce(() =>
+ (parameterContextService.getParameterContexts as
Mock).mockImplementationOnce(() =>
throwError(() => error)
);
@@ -173,7 +297,7 @@ describe('ParameterContextListingEffects', () => {
action$.next(ParameterContextListingActions.loadParameterContexts());
const error = new HttpErrorResponse({ status: 500 });
- (parameterContextService.getParameterContexts as
vi.Mock).mockImplementationOnce(() =>
+ (parameterContextService.getParameterContexts as
Mock).mockImplementationOnce(() =>
throwError(() => error)
);
@@ -342,8 +466,251 @@ describe('ParameterContextListingEffects', () => {
action$.next(ParameterContextListingActions.pollParameterContextUpdateRequestSuccess({
response }));
- // Since the effect is synchronous with filter, we can check
immediately
expect(emissions).toEqual([]);
});
});
+
+ describe('openParameterContextDialog$ goToParameter', () => {
+ it('should navigate immediately when the form is clean', async () => {
+ const { effects, dialogOpen, router, editDialogRef } = await
setup({ formDirty: false });
+ const subscription = await openEditDialog(effects);
+
+ expect(dialogOpen).toHaveBeenCalledWith(EditParameterContext,
expect.anything());
+
+ (editDialogRef.componentInstance.goToParameter as (id: string,
name: string) => void)(
+ 'pc-inherited',
+ 'inherited-param'
+ );
+
+
expect(router.navigate).toHaveBeenCalledWith(['/parameter-contexts',
'pc-inherited', 'edit'], {
+ state: {
+ backNavigation: {
+ route: ['/parameter-contexts', 'pc-source', 'edit'],
+ routeBoundary: ['/parameter-contexts'],
+ context: 'Parameter Context'
+ },
+ highlightedParameterName: 'inherited-param'
+ }
+ });
+
+ subscription.unsubscribe();
+ });
+
+ it('should open the save-changes dialog with canSave true when the
form is dirty and valid', async () => {
+ const { effects, dialogOpen, editDialogRef } = await setup({
formDirty: true, formValid: true });
+ const subscription = await openEditDialog(effects);
+
+ (editDialogRef.componentInstance.goToParameter as (id: string,
name: string) => void)(
+ 'pc-inherited',
+ 'inherited-param'
+ );
+
+ expect(dialogOpen).toHaveBeenCalledWith(
+ SaveParameterContextChangesDialog,
+ expect.objectContaining({
+ data: {
+ destination: 'Parameter',
+ canSave: true
+ }
+ })
+ );
+ subscription.unsubscribe();
+ });
+
+ it('should open the save-changes dialog with canSave false when the
form is dirty and invalid', async () => {
+ const { effects, dialogOpen, editDialogRef } = await setup({
formDirty: true, formValid: false });
+ const subscription = await openEditDialog(effects);
+
+ (editDialogRef.componentInstance.goToParameter as (id: string,
name: string) => void)(
+ 'pc-inherited',
+ 'inherited-param'
+ );
+
+ expect(dialogOpen).toHaveBeenCalledWith(
+ SaveParameterContextChangesDialog,
+ expect.objectContaining({
+ data: {
+ destination: 'Parameter',
+ canSave: false
+ }
+ })
+ );
+ subscription.unsubscribe();
+ });
+
+ it('should submitForm with postUpdateNavigation when dirty and Save is
chosen', async () => {
+ const { effects, editDialogRef, submitForm, save, router } = await
setup({
+ formDirty: true,
+ formValid: true
+ });
+ const subscription = await openEditDialog(effects);
+
+ (editDialogRef.componentInstance.goToParameter as (id: string,
name: string) => void)(
+ 'pc-inherited',
+ 'inherited-param'
+ );
+ save.next();
+
+ expect(submitForm).toHaveBeenCalledWith(
+ ['/parameter-contexts', 'pc-inherited', 'edit'],
+ ['/parameter-contexts'],
+ { highlightedParameterName: 'inherited-param' }
+ );
+ expect(router.navigate).not.toHaveBeenCalled();
+ subscription.unsubscribe();
+ });
+
+ it('should not submitForm when dirty, invalid, and Save is emitted',
async () => {
+ const { effects, editDialogRef, submitForm, save, router } = await
setup({
+ formDirty: true,
+ formValid: false
+ });
+ const subscription = await openEditDialog(effects);
+
+ (editDialogRef.componentInstance.goToParameter as (id: string,
name: string) => void)(
+ 'pc-inherited',
+ 'inherited-param'
+ );
+ save.next();
+
+ expect(submitForm).not.toHaveBeenCalled();
+ expect(router.navigate).not.toHaveBeenCalled();
+ subscription.unsubscribe();
+ });
+
+ it("should navigate without submit when dirty and Don't Save is
chosen", async () => {
+ const { effects, editDialogRef, submitForm, discard, router } =
await setup({
+ formDirty: true,
+ formValid: true
+ });
+ const subscription = await openEditDialog(effects);
+
+ (editDialogRef.componentInstance.goToParameter as (id: string,
name: string) => void)(
+ 'pc-inherited',
+ 'inherited-param'
+ );
+ discard.next();
+
+ expect(submitForm).not.toHaveBeenCalled();
+
expect(router.navigate).toHaveBeenCalledWith(['/parameter-contexts',
'pc-inherited', 'edit'], {
+ state: {
+ backNavigation: {
+ route: ['/parameter-contexts', 'pc-source', 'edit'],
+ routeBoundary: ['/parameter-contexts'],
+ context: 'Parameter Context'
+ },
+ highlightedParameterName: 'inherited-param'
+ }
+ });
+ subscription.unsubscribe();
+ });
+
+ it("should navigate without submit when dirty, invalid, and Don't Save
is chosen", async () => {
+ const { effects, editDialogRef, submitForm, discard, router } =
await setup({
+ formDirty: true,
+ formValid: false
+ });
+ const subscription = await openEditDialog(effects);
+
+ (editDialogRef.componentInstance.goToParameter as (id: string,
name: string) => void)(
+ 'pc-inherited',
+ 'inherited-param'
+ );
+ discard.next();
+
+ expect(submitForm).not.toHaveBeenCalled();
+
expect(router.navigate).toHaveBeenCalledWith(['/parameter-contexts',
'pc-inherited', 'edit'], {
+ state: {
+ backNavigation: {
+ route: ['/parameter-contexts', 'pc-source', 'edit'],
+ routeBoundary: ['/parameter-contexts'],
+ context: 'Parameter Context'
+ },
+ highlightedParameterName: 'inherited-param'
+ }
+ });
+ subscription.unsubscribe();
+ });
+
+ it('should neither submit nor navigate when dirty and the save-changes
dialog is closed without an action', async () => {
+ const { effects, editDialogRef, submitForm, router,
saveChangesAfterClosed$ } = await setup({
+ formDirty: true,
+ formValid: true
+ });
+ const subscription = await openEditDialog(effects);
+
+ (editDialogRef.componentInstance.goToParameter as (id: string,
name: string) => void)(
+ 'pc-inherited',
+ 'inherited-param'
+ );
+ saveChangesAfterClosed$.next();
+
+ expect(submitForm).not.toHaveBeenCalled();
+ expect(router.navigate).not.toHaveBeenCalled();
+ subscription.unsubscribe();
+ });
+
+ it('should unwrap editParameterContext emit into
submitParameterContextUpdateRequest', async () => {
+ const { effects, editDialogRef, dispatchSpy } = await setup();
+ const subscription = await openEditDialog(effects);
+
+ const update: EditParameterContextUpdate = {
+ payload: { id: 'pc-source', component: { id: 'pc-source' } },
+ postUpdateNavigation: ['/parameter-contexts', 'pc-inherited',
'edit'],
+ postUpdateNavigationBoundary: ['/parameter-contexts'],
+ postUpdateNavigationState: { highlightedParameterName:
'inherited-param' }
+ };
+ editDialogRef.componentInstance.editParameterContext.next(update);
+
+ expect(dispatchSpy).toHaveBeenCalledWith(
+
ParameterContextListingActions.submitParameterContextUpdateRequest({
+ request: {
+ id: 'pc-source',
+ payload: update.payload,
+ postUpdateNavigation: update.postUpdateNavigation,
+ postUpdateNavigationBoundary:
update.postUpdateNavigationBoundary,
+ postUpdateNavigationState:
update.postUpdateNavigationState
+ }
+ })
+ );
+ subscription.unsubscribe();
+ });
+
+ it('should navigate when continuePostUpdateNavigation is emitted with
pending navigation', async () => {
+ const { effects, continuePostUpdateNavigation, router } = await
setup({
+ postUpdateNavigation: ['/parameter-contexts', 'pc-inherited',
'edit'],
+ postUpdateNavigationBoundary: ['/parameter-contexts'],
+ postUpdateNavigationState: { highlightedParameterName:
'inherited-param' }
+ });
+ const subscription = await openEditDialog(effects);
+
+ continuePostUpdateNavigation.next();
+ await Promise.resolve();
+
+
expect(router.navigate).toHaveBeenCalledWith(['/parameter-contexts',
'pc-inherited', 'edit'], {
+ state: {
+ backNavigation: {
+ route: ['/parameter-contexts', 'pc-source', 'edit'],
+ routeBoundary: ['/parameter-contexts'],
+ context: 'Parameter Context'
+ },
+ highlightedParameterName: 'inherited-param'
+ }
+ });
+ subscription.unsubscribe();
+ });
+
+ it('should not navigate when continuePostUpdateNavigation is emitted
without pending navigation', async () => {
+ const { effects, continuePostUpdateNavigation, router } = await
setup({
+ postUpdateNavigation: null
+ });
+ const subscription = await openEditDialog(effects);
+
+ continuePostUpdateNavigation.next();
+ await Promise.resolve();
+
+ expect(router.navigate).not.toHaveBeenCalled();
+ subscription.unsubscribe();
+ });
+ });
});
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 74b9a005cb8..493c88a3071 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
@@ -34,7 +34,7 @@ import {
takeUntil,
tap
} from 'rxjs';
-import { MatDialog } from '@angular/material/dialog';
+import { MatDialog, MatDialogRef } from '@angular/material/dialog';
import { Store } from '@ngrx/store';
import { NiFiState } from '../../../../state';
import { Router } from '@angular/router';
@@ -46,9 +46,18 @@ import {
selectSaving,
selectUpdateRequest,
selectUpdateRequestParameterContextId,
- selectDeleteUpdateRequestInitiated
+ selectDeleteUpdateRequestInitiated,
+ selectHasPendingPostUpdateNavigation,
+ selectPostUpdateNavigation,
+ selectPostUpdateNavigationBoundary,
+ selectPostUpdateNavigationState
} from './parameter-context-listing.selectors';
-import { EditParameterRequest, EditParameterResponse,
ParameterContextUpdateRequest } from '../../../../state/shared';
+import {
+ EditParameterRequest,
+ EditParameterResponse,
+ ParameterContextUpdateRequest,
+ PostUpdateNavigationState
+} from '../../../../state/shared';
import { EditParameterDialog } from
'../../../../ui/common/edit-parameter-dialog/edit-parameter-dialog.component';
import { OkDialog } from '../../../../ui/common/ok-dialog/ok-dialog.component';
import { ErrorHelper } from '../../../../service/error-helper.service';
@@ -58,6 +67,8 @@ import { BackNavigation } from '../../../../state/navigation';
import { isDefinedAndNotNull, MEDIUM_DIALOG, SMALL_DIALOG, XL_DIALOG,
NiFiCommon, Storage } from '@nifi/shared';
import { ErrorContextKey } from '../../../../state/error';
import { EditParameterContext } from
'../../../../ui/common/parameter-context/edit-parameter-context/edit-parameter-context.component';
+import { SaveParameterContextChangesDialog } from
'../../../../ui/common/parameter-context/save-parameter-context-changes-dialog/save-parameter-context-changes-dialog.component';
+import { EditParameterContextUpdate } from
'../../../../ui/common/parameter-context';
@Injectable()
export class ParameterContextListingEffects {
@@ -295,7 +306,8 @@ export class ParameterContextListingEffects {
map((response) =>
ParameterContextListingActions.openParameterContextDialog({
request: {
- parameterContext: response
+ parameterContext: response,
+ highlightedParameterName:
request.highlightedParameterName
}
})
),
@@ -326,7 +338,8 @@ export class ParameterContextListingEffects {
const editDialogReference =
this.dialog.open(EditParameterContext, {
...XL_DIALOG,
data: {
- parameterContext: request.parameterContext
+ parameterContext: request.parameterContext,
+ highlightedParameterName:
request.highlightedParameterName
}
});
@@ -338,6 +351,27 @@ export class ParameterContextListingEffects {
map((parameterContexts) =>
parameterContexts.filter((pc) => pc.id != parameterContextId))
);
editDialogReference.componentInstance.saving$ =
this.store.select(selectSaving);
+
editDialogReference.componentInstance.hasPendingPostUpdateNavigation$ =
this.store.select(
+ selectHasPendingPostUpdateNavigation
+ );
+
+ editDialogReference.componentInstance.goToParameter = (
+ inheritedParameterContextId: string,
+ parameterName: string
+ ) => {
+ const commandBoundary: string[] =
['/parameter-contexts'];
+ const commands: string[] = [...commandBoundary,
inheritedParameterContextId, 'edit'];
+ this.goToInheritedParameter(
+ editDialogReference,
+ parameterContextId,
+ commands,
+ commandBoundary,
+ 'Parameter',
+ {
+ highlightedParameterName: parameterName
+ }
+ );
+ };
editDialogReference.componentInstance.createNewParameter =
(
existingParameters: string[]
@@ -393,17 +427,46 @@ export class ParameterContextListingEffects {
editDialogReference.componentInstance.editParameterContext
.pipe(takeUntil(editDialogReference.afterClosed()))
- .subscribe((payload: any) => {
+ .subscribe((updateRequest: EditParameterContextUpdate)
=> {
this.store.dispatch(
ParameterContextListingActions.submitParameterContextUpdateRequest({
request: {
id: parameterContextId,
- payload
+ payload: updateRequest.payload,
+ postUpdateNavigation:
updateRequest.postUpdateNavigation,
+ postUpdateNavigationBoundary:
updateRequest.postUpdateNavigationBoundary,
+ postUpdateNavigationState:
updateRequest.postUpdateNavigationState
}
})
);
});
+
editDialogReference.componentInstance.continuePostUpdateNavigation
+ .pipe(
+ takeUntil(editDialogReference.afterClosed()),
+ switchMap(() =>
+
this.store.select(selectPostUpdateNavigation).pipe(
+ take(1),
+ concatLatestFrom(() => [
+
this.store.select(selectPostUpdateNavigationBoundary),
+
this.store.select(selectPostUpdateNavigationState)
+ ])
+ )
+ )
+ )
+ .subscribe(
+ ([postUpdateNavigation,
postUpdateNavigationBoundary, postUpdateNavigationState]) => {
+ if (postUpdateNavigation) {
+ this.navigateToInheritedParameter(
+ parameterContextId,
+ postUpdateNavigation,
+ postUpdateNavigationBoundary ??
['/parameter-contexts'],
+ postUpdateNavigationState ?? undefined
+ );
+ }
+ }
+ );
+
editDialogReference.componentInstance.cancelUpdateRequest
.pipe(takeUntil(editDialogReference.afterClosed()))
.subscribe(() => {
@@ -654,4 +717,70 @@ export class ParameterContextListingEffects {
),
{ dispatch: false }
);
+
+ /**
+ * Navigates to the supplied route, recording back navigation to the
Parameter Context that was being edited.
+ */
+ private navigateToInheritedParameter(
+ sourceParameterContextId: string,
+ commands: string[],
+ commandBoundary: string[],
+ navigationState?: PostUpdateNavigationState
+ ): void {
+ this.router.navigate(commands, {
+ state: {
+ backNavigation: {
+ route: ['/parameter-contexts', sourceParameterContextId,
'edit'],
+ routeBoundary: commandBoundary,
+ context: 'Parameter Context'
+ } as BackNavigation,
+ ...navigationState
+ }
+ });
+ }
+
+ /**
+ * Navigates to an inherited Parameter. When the Parameter Context edit
form has unsaved changes the user is
+ * prompted to save or discard them first. Saving defers the navigation
until the update request completes and
+ * the user opts to continue.
+ */
+ private goToInheritedParameter(
+ editDialogReference: MatDialogRef<EditParameterContext>,
+ sourceParameterContextId: string,
+ commands: string[],
+ commandBoundary: string[],
+ destination: string,
+ navigationState?: PostUpdateNavigationState
+ ): void {
+ const editParameterContextForm =
editDialogReference.componentInstance.editParameterContextForm;
+
+ if (!editParameterContextForm.dirty) {
+ this.navigateToInheritedParameter(sourceParameterContextId,
commands, commandBoundary, navigationState);
+ return;
+ }
+
+ const saveChangesDialogReference =
this.dialog.open(SaveParameterContextChangesDialog, {
+ ...SMALL_DIALOG,
+ data: {
+ destination,
+ canSave: editParameterContextForm.valid
+ }
+ });
+
+ // Defense in depth: Save is disabled when invalid, but still guard
before submit.
+ saveChangesDialogReference.componentInstance.save
+ .pipe(takeUntil(saveChangesDialogReference.afterClosed()), take(1))
+ .subscribe(() => {
+ if (!editParameterContextForm.valid) {
+ return;
+ }
+ editDialogReference.componentInstance.submitForm(commands,
commandBoundary, navigationState);
+ });
+
+ saveChangesDialogReference.componentInstance.discard
+ .pipe(takeUntil(saveChangesDialogReference.afterClosed()), take(1))
+ .subscribe(() => {
+ this.navigateToInheritedParameter(sourceParameterContextId,
commands, commandBoundary, navigationState);
+ });
+ }
}
diff --git
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/parameter-contexts/state/parameter-context-listing/parameter-context-listing.reducer.spec.ts
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/parameter-contexts/state/parameter-context-listing/parameter-context-listing.reducer.spec.ts
new file mode 100644
index 00000000000..e70364b39e9
--- /dev/null
+++
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/parameter-contexts/state/parameter-context-listing/parameter-context-listing.reducer.spec.ts
@@ -0,0 +1,151 @@
+/*
+ * 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 { initialState, parameterContextListingReducer } from
'./parameter-context-listing.reducer';
+import {
+ deleteParameterContextUpdateRequestSuccess,
+ editParameterContextComplete,
+ parameterContextListingBannerApiError,
+ submitParameterContextUpdateRequest
+} from './parameter-context-listing.actions';
+import { ParameterContextListingState } from './index';
+
+describe('ParameterContextListing Reducer', () => {
+ describe('submitParameterContextUpdateRequest', () => {
+ it('should persist postUpdateNavigation fields from the request', ()
=> {
+ const result = parameterContextListingReducer(
+ initialState,
+ submitParameterContextUpdateRequest({
+ request: {
+ id: 'pc-1',
+ payload: {},
+ postUpdateNavigation: ['/parameter-contexts',
'inherited-id', 'edit'],
+ postUpdateNavigationBoundary: ['/parameter-contexts'],
+ postUpdateNavigationState: { highlightedParameterName:
'param-a' }
+ }
+ })
+ );
+
+ expect(result.saving).toBe(true);
+ expect(result.updateRequestParameterContextId).toBe('pc-1');
+
expect(result.postUpdateNavigation).toEqual(['/parameter-contexts',
'inherited-id', 'edit']);
+
expect(result.postUpdateNavigationBoundary).toEqual(['/parameter-contexts']);
+ expect(result.postUpdateNavigationState).toEqual({
highlightedParameterName: 'param-a' });
+ });
+
+ it('should clear postUpdateNavigation fields when not provided on
submit', () => {
+ const stateWithPendingNav: ParameterContextListingState = {
+ ...initialState,
+ postUpdateNavigation: ['/parameter-contexts', 'old-id',
'edit'],
+ postUpdateNavigationBoundary: ['/parameter-contexts'],
+ postUpdateNavigationState: { highlightedParameterName: 'old' }
+ };
+
+ const result = parameterContextListingReducer(
+ stateWithPendingNav,
+ submitParameterContextUpdateRequest({
+ request: {
+ id: 'pc-1',
+ payload: {}
+ }
+ })
+ );
+
+ expect(result.postUpdateNavigation).toBeNull();
+ expect(result.postUpdateNavigationBoundary).toBeNull();
+ expect(result.postUpdateNavigationState).toBeNull();
+ });
+ });
+
+ describe('deleteParameterContextUpdateRequestSuccess', () => {
+ it('should keep postUpdateNavigation fields for the review CTA', () =>
{
+ const stateWithPendingNav: ParameterContextListingState = {
+ ...initialState,
+ saving: true,
+ postUpdateNavigation: ['/parameter-contexts', 'inherited-id',
'edit'],
+ postUpdateNavigationBoundary: ['/parameter-contexts'],
+ postUpdateNavigationState: { highlightedParameterName:
'param-a' },
+ updateRequestEntity: {
+ request: {
+ requestId: 'req-1',
+ uri: '',
+ lastUpdated: '',
+ complete: true,
+ percentComponent: 100,
+ state: 'Complete',
+ updateSteps: [],
+ referencingComponents: []
+ },
+ parameterContextRevision: { version: 1 }
+ }
+ };
+
+ const result = parameterContextListingReducer(
+ stateWithPendingNav,
+ deleteParameterContextUpdateRequestSuccess({
+ response: {
+ requestEntity: stateWithPendingNav.updateRequestEntity!
+ }
+ })
+ );
+
+ expect(result.saving).toBe(false);
+
expect(result.postUpdateNavigation).toEqual(['/parameter-contexts',
'inherited-id', 'edit']);
+
expect(result.postUpdateNavigationBoundary).toEqual(['/parameter-contexts']);
+ expect(result.postUpdateNavigationState).toEqual({
highlightedParameterName: 'param-a' });
+ });
+ });
+
+ describe('parameterContextListingBannerApiError', () => {
+ it('should clear postUpdateNavigation fields on error', () => {
+ const stateWithPendingNav: ParameterContextListingState = {
+ ...initialState,
+ saving: true,
+ postUpdateNavigation: ['/parameter-contexts', 'inherited-id',
'edit'],
+ postUpdateNavigationBoundary: ['/parameter-contexts'],
+ postUpdateNavigationState: { highlightedParameterName:
'param-a' }
+ };
+
+ const result = parameterContextListingReducer(
+ stateWithPendingNav,
+ parameterContextListingBannerApiError({ error: 'boom' })
+ );
+
+ expect(result.saving).toBe(false);
+ expect(result.postUpdateNavigation).toBeNull();
+ expect(result.postUpdateNavigationBoundary).toBeNull();
+ expect(result.postUpdateNavigationState).toBeNull();
+ });
+ });
+
+ describe('editParameterContextComplete', () => {
+ it('should clear postUpdateNavigation fields when the dialog closes',
() => {
+ const stateWithPendingNav: ParameterContextListingState = {
+ ...initialState,
+ postUpdateNavigation: ['/parameter-contexts', 'inherited-id',
'edit'],
+ postUpdateNavigationBoundary: ['/parameter-contexts'],
+ postUpdateNavigationState: { highlightedParameterName:
'param-a' }
+ };
+
+ const result = parameterContextListingReducer(stateWithPendingNav,
editParameterContextComplete());
+
+ expect(result.postUpdateNavigation).toBeNull();
+ expect(result.postUpdateNavigationBoundary).toBeNull();
+ expect(result.postUpdateNavigationState).toBeNull();
+ });
+ });
+});
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 018a78c0356..c06626dd7ab 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
@@ -41,6 +41,9 @@ export const initialState: ParameterContextListingState = {
parameterContexts: [],
updateRequestEntity: null,
updateRequestParameterContextId: null,
+ postUpdateNavigation: null,
+ postUpdateNavigationBoundary: null,
+ postUpdateNavigationState: null,
saving: false,
loadedTimestamp: '',
deleteUpdateRequestInitiated: false,
@@ -66,7 +69,10 @@ export const parameterContextListingReducer = createReducer(
})),
on(parameterContextListingSnackbarApiError,
parameterContextListingBannerApiError, (state) => ({
...state,
- saving: false
+ saving: false,
+ postUpdateNavigation: null,
+ postUpdateNavigationBoundary: null,
+ postUpdateNavigationState: null
})),
on(createParameterContext, (state) => ({
...state,
@@ -81,7 +87,10 @@ export const parameterContextListingReducer = createReducer(
on(submitParameterContextUpdateRequest, (state, { request }) => ({
...state,
saving: true,
- updateRequestParameterContextId: request.id
+ updateRequestParameterContextId: request.id,
+ postUpdateNavigation: request.postUpdateNavigation ?? null,
+ postUpdateNavigationBoundary: request.postUpdateNavigationBoundary ??
null,
+ postUpdateNavigationState: request.postUpdateNavigationState ?? null
})),
on(
submitParameterContextUpdateRequestSuccess,
@@ -132,6 +141,10 @@ export const parameterContextListingReducer =
createReducer(
draftState.saving = false;
draftState.deleteUpdateRequestInitiated = false;
}
+
+ draftState.postUpdateNavigation = null;
+ draftState.postUpdateNavigationBoundary = null;
+ draftState.postUpdateNavigationState = null;
});
}),
on(deleteParameterContextSuccess, (state, { response }) => {
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 bc181a2e3a9..6797fe172f6 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
@@ -74,3 +74,23 @@ export const selectDeleteUpdateRequestInitiated =
createSelector(
selectParameterContextListingState,
(state: ParameterContextListingState) => state.deleteUpdateRequestInitiated
);
+
+export const selectPostUpdateNavigation = createSelector(
+ selectParameterContextListingState,
+ (state: ParameterContextListingState) => state.postUpdateNavigation
+);
+
+export const selectPostUpdateNavigationBoundary = createSelector(
+ selectParameterContextListingState,
+ (state: ParameterContextListingState) => state.postUpdateNavigationBoundary
+);
+
+export const selectPostUpdateNavigationState = createSelector(
+ selectParameterContextListingState,
+ (state: ParameterContextListingState) => state.postUpdateNavigationState
+);
+
+export const selectHasPendingPostUpdateNavigation = createSelector(
+ selectPostUpdateNavigation,
+ (postUpdateNavigation) => postUpdateNavigation != null &&
postUpdateNavigation.length > 0
+);
diff --git
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/parameter-contexts/ui/parameter-context-listing/parameter-context-listing.component.spec.ts
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/parameter-contexts/ui/parameter-context-listing/parameter-context-listing.component.spec.ts
index 3e2297ee144..ebf29139b6b 100644
---
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/parameter-contexts/ui/parameter-context-listing/parameter-context-listing.component.spec.ts
+++
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/parameter-contexts/ui/parameter-context-listing/parameter-context-listing.component.spec.ts
@@ -18,34 +18,136 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { ParameterContextListing } from
'./parameter-context-listing.component';
-import { provideMockStore } from '@ngrx/store/testing';
+import { MockStore, provideMockStore } from '@ngrx/store/testing';
import { initialState } from
'../../state/parameter-context-listing/parameter-context-listing.reducer';
import { parameterContextListingFeatureKey } from
'../../state/parameter-context-listing';
import { parameterContextsFeatureKey } from '../../state';
+import { selectSingleEditedParameterContext } from
'../../state/parameter-context-listing/parameter-context-listing.selectors';
+import { getEffectiveParameterContextAndOpenDialog } from
'../../state/parameter-context-listing/parameter-context-listing.actions';
+import { Navigation, Router } from '@angular/router';
+import { ParameterContextEntity } from '../../../../state/shared';
describe('ParameterContextListing', () => {
let component: ParameterContextListing;
let fixture: ComponentFixture<ParameterContextListing>;
- beforeEach(() => {
+ const parameterContext: ParameterContextEntity = {
+ revision: {
+ version: 0
+ },
+ id: '1234',
+ uri: 'https://localhost:4200/nifi-api/parameter-contexts/1234',
+ permissions: {
+ canRead: true,
+ canWrite: true
+ },
+ component: {
+ name: 'params 1',
+ description: '',
+ parameters: [],
+ boundProcessGroups: [],
+ inheritedParameterContexts: [],
+ id: '1234'
+ }
+ };
+
+ let lastSuccessfulNavigation: Navigation | null = null;
+
+ const configureTestBed = (editedParameterContextId: string | null,
parameterContexts: ParameterContextEntity[]) => {
TestBed.configureTestingModule({
imports: [ParameterContextListing],
providers: [
provideMockStore({
initialState: {
[parameterContextsFeatureKey]: {
- [parameterContextListingFeatureKey]: initialState
+ [parameterContextListingFeatureKey]: {
+ ...initialState,
+ parameterContexts
+ }
}
}
- })
+ }),
+ {
+ provide: Router,
+ useValue: {
+ lastSuccessfulNavigation: () =>
lastSuccessfulNavigation
+ }
+ }
]
});
+
+ const store: MockStore = TestBed.inject(MockStore);
+ store.overrideSelector(selectSingleEditedParameterContext,
editedParameterContextId);
+
+ return store;
+ };
+
+ beforeEach(() => {
+ lastSuccessfulNavigation = null;
+ });
+
+ it('should create', () => {
+ configureTestBed(null, []);
fixture = TestBed.createComponent(ParameterContextListing);
component = fixture.componentInstance;
fixture.detectChanges();
- });
- it('should create', () => {
expect(component).toBeTruthy();
});
+
+ describe('when the edit route is active', () => {
+ it('should open the dialog with the highlighted parameter from the
navigation state', () => {
+ lastSuccessfulNavigation = {
+ extras: {
+ state: {
+ highlightedParameterName: 'param A'
+ }
+ }
+ } as unknown as Navigation;
+
+ const store = configureTestBed(parameterContext.id,
[parameterContext]);
+ const dispatchSpy = vi.spyOn(store, 'dispatch');
+
+ fixture = TestBed.createComponent(ParameterContextListing);
+ fixture.detectChanges();
+
+ expect(dispatchSpy).toHaveBeenCalledWith(
+ getEffectiveParameterContextAndOpenDialog({
+ request: {
+ id: parameterContext.id,
+ highlightedParameterName: 'param A'
+ }
+ })
+ );
+ });
+
+ it('should open the dialog without a highlighted parameter when the
navigation state is absent', () => {
+ const store = configureTestBed(parameterContext.id,
[parameterContext]);
+ const dispatchSpy = vi.spyOn(store, 'dispatch');
+
+ fixture = TestBed.createComponent(ParameterContextListing);
+ fixture.detectChanges();
+
+ expect(dispatchSpy).toHaveBeenCalledWith(
+ getEffectiveParameterContextAndOpenDialog({
+ request: {
+ id: parameterContext.id,
+ highlightedParameterName: undefined
+ }
+ })
+ );
+ });
+
+ it('should not open the dialog until the parameter context is loaded',
() => {
+ const store = configureTestBed(parameterContext.id, []);
+ const dispatchSpy = vi.spyOn(store, 'dispatch');
+
+ fixture = TestBed.createComponent(ParameterContextListing);
+ fixture.detectChanges();
+
+ expect(dispatchSpy).not.toHaveBeenCalledWith(
+ expect.objectContaining({ type:
getEffectiveParameterContextAndOpenDialog.type })
+ );
+ });
+ });
});
diff --git
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/parameter-contexts/ui/parameter-context-listing/parameter-context-listing.component.ts
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/parameter-contexts/ui/parameter-context-listing/parameter-context-listing.component.ts
index 182989ffac2..eb5d61e1456 100644
---
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/parameter-contexts/ui/parameter-context-listing/parameter-context-listing.component.ts
+++
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/parameter-contexts/ui/parameter-context-listing/parameter-context-listing.component.ts
@@ -17,6 +17,7 @@
import { Component, OnInit, inject } from '@angular/core';
import { Store } from '@ngrx/store';
+import { Router } from '@angular/router';
import { ParameterContextListingState } from
'../../state/parameter-context-listing';
import {
selectContext,
@@ -34,7 +35,7 @@ import {
selectParameterContext
} from
'../../state/parameter-context-listing/parameter-context-listing.actions';
import { initialState } from
'../../state/parameter-context-listing/parameter-context-listing.reducer';
-import { filter, switchMap, take } from 'rxjs';
+import { filter, map, switchMap, take } from 'rxjs';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { selectCurrentUser } from
'../../../../state/current-user/current-user.selectors';
import { selectFlowConfiguration } from
'../../../../state/flow-configuration/flow-configuration.selectors';
@@ -52,6 +53,7 @@ import { ParameterContextTable } from
'./parameter-context-table/parameter-conte
})
export class ParameterContextListing implements OnInit {
private store = inject<Store<ParameterContextListingState>>(Store);
+ private router = inject(Router);
parameterContextListingState$ =
this.store.select(selectParameterContextListingState);
selectedParameterContextId$ =
this.store.select(selectParameterContextIdFromRoute);
@@ -63,20 +65,30 @@ export class ParameterContextListing implements OnInit {
.select(selectSingleEditedParameterContext)
.pipe(
filter((id: string) => id != null),
- switchMap((id: string) =>
+ // capture the highlighted parameter when the route resolves,
before waiting on the parameter
+ // context to load, so a subsequent navigation cannot be
attributed to this request
+ map((id: string) => ({
+ id,
+ highlightedParameterName:
this.router.lastSuccessfulNavigation()?.extras?.state?.[
+ 'highlightedParameterName'
+ ] as string | undefined
+ })),
+ switchMap(({ id, highlightedParameterName }) =>
this.store.select(selectContext(id)).pipe(
filter((entity) => entity != null),
- take(1)
+ take(1),
+ map((entity) => ({ entity, highlightedParameterName }))
)
),
takeUntilDestroyed()
)
- .subscribe((entity) => {
+ .subscribe(({ entity, highlightedParameterName }) => {
if (entity) {
this.store.dispatch(
getEffectiveParameterContextAndOpenDialog({
request: {
- id: entity.id
+ id: entity.id,
+ highlightedParameterName
}
})
);
diff --git
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/parameter-contexts/ui/parameter-context-listing/parameter-table/parameter-table.component.html
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/parameter-contexts/ui/parameter-context-listing/parameter-table/parameter-table.component.html
index eadeaaf17e9..004fd55d345 100644
---
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/parameter-contexts/ui/parameter-context-listing/parameter-table/parameter-table.component.html
+++
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/parameter-contexts/ui/parameter-context-listing/parameter-table/parameter-table.component.html
@@ -137,7 +137,7 @@
}
<mat-menu #actionMenu="matMenu"
xPosition="before">
@if (canGoToParameter(item)) {
- <button mat-menu-item
[routerLink]="getParameterLink(item)">
+ <button aria-label="Go to"
mat-menu-item (click)="goToParameterClicked(item)">
<i class="fa fa-long-arrow-right
primary-color mr-2"></i>
Go to
</button>
@@ -171,6 +171,7 @@
*matRowDef="let row; let even = even; columns:
displayedColumns"
(click)="selectParameter(row)"
(dblclick)="doubleClicked(row)"
+
[attr.data-parameter-name]="row.originalEntity.parameter.name"
[class.selected]="isSelected(row)"
[class.even]="even"></tr>
</table>
diff --git
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/parameter-contexts/ui/parameter-context-listing/parameter-table/parameter-table.component.spec.ts
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/parameter-contexts/ui/parameter-context-listing/parameter-table/parameter-table.component.spec.ts
index 4fdd000a73b..a4e168632e5 100644
---
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/parameter-contexts/ui/parameter-context-listing/parameter-table/parameter-table.component.spec.ts
+++
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/parameter-contexts/ui/parameter-context-listing/parameter-table/parameter-table.component.spec.ts
@@ -422,6 +422,167 @@ describe('ParameterTable', () => {
});
});
+ describe('goToParameterClicked', () => {
+ it('should invoke goToParameter with inherited parameter context id
and parameter name', () => {
+ const goToParameterSpy = vi.fn();
+ component.goToParameter = goToParameterSpy;
+
+ const item: ParameterItem = {
+ added: false,
+ dirty: false,
+ deleted: false,
+ originalEntity: {
+ parameter: {
+ name: 'inherited-param',
+ value: 'value',
+ description: 'asdf',
+ sensitive: false,
+ inherited: true,
+ parameterContext: {
+ id: 'inherited-context-id',
+ permissions: {
+ canRead: true,
+ canWrite: true
+ },
+ component: {
+ id: 'inherited-context-id',
+ name: 'Inherited Context'
+ }
+ }
+ },
+ canWrite: true
+ }
+ };
+
+ component.goToParameterClicked(item);
+
+
expect(goToParameterSpy).toHaveBeenCalledWith('inherited-context-id',
'inherited-param');
+ });
+
+ it('should not invoke goToParameter when parameterContext is missing',
() => {
+ const goToParameterSpy = vi.fn();
+ component.goToParameter = goToParameterSpy;
+
+ const item: ParameterItem = {
+ added: false,
+ dirty: false,
+ deleted: false,
+ originalEntity: {
+ parameter: {
+ name: 'inherited-param',
+ value: 'value',
+ description: 'asdf',
+ sensitive: false,
+ inherited: true
+ },
+ canWrite: true
+ }
+ };
+
+ component.goToParameterClicked(item);
+
+ expect(goToParameterSpy).not.toHaveBeenCalled();
+ });
+
+ it('should no-op goToParameterClicked when goToParameter callback is
not supplied', () => {
+ expect(component.goToParameter).toBeUndefined();
+
+ const item: ParameterItem = {
+ added: false,
+ dirty: false,
+ deleted: false,
+ originalEntity: {
+ parameter: {
+ name: 'inherited-param',
+ value: 'value',
+ description: 'asdf',
+ sensitive: false,
+ inherited: true,
+ parameterContext: {
+ id: 'inherited-context-id',
+ permissions: {
+ canRead: true,
+ canWrite: true
+ },
+ component: {
+ id: 'inherited-context-id',
+ name: 'Inherited Context'
+ }
+ }
+ },
+ canWrite: true
+ }
+ };
+
+ expect(() => component.goToParameterClicked(item)).not.toThrow();
+ });
+
+ it('canGoToParameter returns false when goToParameter callback is not
supplied', () => {
+ expect(component.goToParameter).toBeUndefined();
+
+ const item: ParameterItem = {
+ added: false,
+ dirty: false,
+ deleted: false,
+ originalEntity: {
+ parameter: {
+ name: 'inherited-param',
+ value: 'value',
+ description: 'asdf',
+ sensitive: false,
+ inherited: true,
+ parameterContext: {
+ id: 'inherited-context-id',
+ permissions: {
+ canRead: true,
+ canWrite: true
+ },
+ component: {
+ id: 'inherited-context-id',
+ name: 'Inherited Context'
+ }
+ }
+ },
+ canWrite: true
+ }
+ };
+
+ expect(component.canGoToParameter(item)).toBe(false);
+ });
+ });
+
+ describe('highlightedParameterName', () => {
+ it('selects the row whose name matches highlightedParameterName', ()
=> {
+ component.writeValue([
+ {
+ parameter: { name: 'alpha', value: 'v', description: '',
sensitive: false },
+ canWrite: true
+ },
+ {
+ parameter: { name: 'beta', value: 'v', description: '',
sensitive: false },
+ canWrite: true
+ }
+ ]);
+ component.highlightedParameterName = 'beta';
+ component.ngAfterViewInit();
+
+
expect(component.selectedItem?.originalEntity.parameter.name).toBe('beta');
+ });
+
+ it('does not select any row when highlightedParameterName is
undefined', () => {
+ component.writeValue([
+ {
+ parameter: { name: 'alpha', value: 'v', description: '',
sensitive: false },
+ canWrite: true
+ }
+ ]);
+ component.highlightedParameterName = undefined;
+ component.ngAfterViewInit();
+
+ expect(component.selectedItem).toBeNull();
+ });
+ });
+
describe('canEdit', () => {
it('should consider inherited and not modified as unable to edit', ()
=> {
const item: ParameterItem = {
diff --git
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/parameter-contexts/ui/parameter-context-listing/parameter-table/parameter-table.component.ts
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/parameter-contexts/ui/parameter-context-listing/parameter-table/parameter-table.component.ts
index 70f615a1e71..ea751fda98d 100644
---
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/parameter-contexts/ui/parameter-context-listing/parameter-table/parameter-table.component.ts
+++
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/parameter-contexts/ui/parameter-context-listing/parameter-table/parameter-table.component.ts
@@ -15,13 +15,22 @@
* limitations under the License.
*/
-import { AfterViewInit, ChangeDetectorRef, Component, forwardRef, Input,
inject } from '@angular/core';
+import {
+ AfterViewInit,
+ ChangeDetectorRef,
+ Component,
+ ElementRef,
+ Injector,
+ Input,
+ afterNextRender,
+ forwardRef,
+ inject
+} from '@angular/core';
import { ControlValueAccessor, FormsModule, NG_VALUE_ACCESSOR } from
'@angular/forms';
import { MatButtonModule } from '@angular/material/button';
import { MatDialogModule } from '@angular/material/dialog';
import { MatTableDataSource, MatTableModule } from '@angular/material/table';
import { NgTemplateOutlet } from '@angular/common';
-import { RouterLink } from '@angular/router';
import { EditParameterResponse, ParameterEntity } from
'../../../../../state/shared';
import { NifiTooltipDirective, NiFiCommon, TextTip, Parameter } from
'@nifi/shared';
import { Observable, take } from 'rxjs';
@@ -51,7 +60,6 @@ export interface ParameterItem {
MatTableModule,
MatSortModule,
NgTemplateOutlet,
- RouterLink,
NifiTooltipDirective,
ParameterReferences,
MatMenu,
@@ -74,11 +82,15 @@ export class ParameterTable implements AfterViewInit,
ControlValueAccessor {
private store = inject<Store<ParameterContextListingState>>(Store);
private changeDetector = inject(ChangeDetectorRef);
private nifiCommon = inject(NiFiCommon);
+ private elementRef = inject(ElementRef);
+ private injector = inject(Injector);
@Input() createNewParameter!: (existingParameters: string[]) =>
Observable<EditParameterResponse>;
@Input() editParameter!: (parameter: Parameter) =>
Observable<EditParameterResponse>;
+ @Input() goToParameter?: (parameterContextId: string, parameterName:
string) => void;
@Input() canAddParameters = true;
@Input() inheritsParameters = false;
+ @Input() highlightedParameterName?: string;
protected readonly TextTip = TextTip;
@@ -101,6 +113,24 @@ export class ParameterTable implements AfterViewInit,
ControlValueAccessor {
ngAfterViewInit(): void {
this.initFilter();
+ if (this.highlightedParameterName) {
+ const match = this.dataSource.data.find(
+ (item) => this.isVisible(item) &&
item.originalEntity.parameter.name === this.highlightedParameterName
+ );
+ if (match) {
+ this.selectParameter(match);
+ afterNextRender(
+ () => {
+ const rows: NodeListOf<HTMLElement> =
+
this.elementRef.nativeElement.querySelectorAll('[data-parameter-name]');
+ Array.from(rows)
+ .find((el) => el.dataset['parameterName'] ===
this.highlightedParameterName)
+ ?.scrollIntoView({ block: 'center', behavior:
'smooth' });
+ },
+ { injector: this.injector }
+ );
+ }
+ }
}
initFilter(): void {
@@ -315,15 +345,19 @@ export class ParameterTable implements AfterViewInit,
ControlValueAccessor {
}
canGoToParameter(item: ParameterItem): boolean {
- return this.canOverride(item) &&
item.originalEntity.parameter.parameterContext?.permissions.canRead == true;
+ return (
+ !!this.goToParameter &&
+ this.canOverride(item) &&
+
item.originalEntity.parameter.parameterContext?.permissions.canRead == true
+ );
}
- getParameterLink(item: ParameterItem): string[] {
- if (item.originalEntity.parameter.parameterContext) {
- // TODO - support routing directly to a parameter
- return ['/parameter-contexts',
item.originalEntity.parameter.parameterContext.id, 'edit'];
+ goToParameterClicked(item: ParameterItem): void {
+ const parameterContext =
item.originalEntity.parameter.parameterContext;
+ if (!this.goToParameter || !parameterContext) {
+ return;
}
- return [];
+ this.goToParameter(parameterContext.id,
item.originalEntity.parameter.name);
}
isOverridden(item: ParameterItem): boolean {
diff --git
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/state/shared/index.ts
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/state/shared/index.ts
index 2472558dccb..e1fafc075bf 100644
--- a/nifi-frontend/src/main/frontend/apps/nifi/src/app/state/shared/index.ts
+++ b/nifi-frontend/src/main/frontend/apps/nifi/src/app/state/shared/index.ts
@@ -549,9 +549,16 @@ export interface ParameterConfig {
parameters: Parameter[] | null;
}
+export interface PostUpdateNavigationState {
+ highlightedParameterName?: string;
+}
+
export interface SubmitParameterContextUpdate {
id: string;
payload: any;
+ postUpdateNavigation?: string[];
+ postUpdateNavigationBoundary?: string[];
+ postUpdateNavigationState?: PostUpdateNavigationState;
}
export interface PollParameterContextUpdateSuccess {
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 9ef15caaf6d..5e43ddedcd9 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
@@ -157,7 +157,9 @@
[canAddParameters]="!request.parameterContext?.component?.parameterProviderConfiguration"
[inheritsParameters]="inheritsParameters(request.parameterContext?.component?.parameters)"
[createNewParameter]="createNewParameter"
- [editParameter]="editParameter"></parameter-table>
+ [editParameter]="editParameter"
+ [goToParameter]="goToParameter"
+
[highlightedParameterName]="request.highlightedParameterName"></parameter-table>
</div>
</mat-dialog-content>
</mat-tab>
@@ -177,18 +179,46 @@
@if ((updateRequest | async)!; as requestEntity) {
<mat-dialog-actions align="end">
@if (requestEntity.request.complete) {
- <button mat-flat-button mat-dialog-close>Close</button>
+ @if ((hasPendingPostUpdateNavigation$ | async)!) {
+ <button
+ mat-button
+ type="button"
+ aria-label="Close"
+ data-qa="edit-parameter-context-close"
+ mat-dialog-close>
+ Close
+ </button>
+ <button
+ mat-flat-button
+ type="button"
+ aria-label="Go to Parameter"
+ data-qa="edit-parameter-context-go-to-parameter"
+ (click)="continuePostUpdateNavigation.emit()">
+ Go to Parameter
+ </button>
+ } @else {
+ <button
+ mat-flat-button
+ type="button"
+ aria-label="Close"
+ data-qa="edit-parameter-context-close"
+ mat-dialog-close>
+ Close
+ </button>
+ }
} @else {
- <button mat-button mat-dialog-close
(click)="cancelUpdateRequest.emit()">Cancel</button>
+ <button mat-button type="button" mat-dialog-close
(click)="cancelUpdateRequest.emit()">Cancel</button>
}
</mat-dialog-actions>
} @else {
@if ({ value: (saving$ | async)! }; as saving) {
<mat-dialog-actions align="end">
@if (readonly) {
- <button mat-flat-button mat-dialog-close>Close</button>
+ <button mat-flat-button type="button"
mat-dialog-close>Close</button>
} @else {
- <button mat-button mat-dialog-close
(click)="cancelUpdateRequest.emit()">Cancel</button>
+ <button mat-button type="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 85c5751d766..fa5c533241d 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
@@ -31,9 +31,10 @@ import { errorFeatureKey } from '../../../../state/error';
import { initialState as initialCurrentUserState } from
'../../../../state/current-user/current-user.reducer';
import { currentUserFeatureKey } from '../../../../state/current-user';
import { ClusterConnectionService } from
'../../../../service/cluster-connection.service';
-import { ParameterContextEntity, ParameterEntity } from
'../../../../state/shared';
+import { ParameterContextEntity, ParameterContextUpdateRequestEntity,
ParameterEntity } from '../../../../state/shared';
-import { EditParameterContextRequest } from '../index';
+import { EditParameterContextRequest } from
'../../../../pages/parameter-contexts/state/parameter-context-listing';
+import { By } from '@angular/platform-browser';
describe('EditParameterContext', () => {
let component: EditParameterContext;
@@ -268,6 +269,9 @@ describe('EditParameterContext', () => {
fixture = TestBed.createComponent(EditParameterContext);
component = fixture.componentInstance;
component.availableParameterContexts$ = of(parameterContexts);
+ component.hasPendingPostUpdateNavigation$ = of(false);
+ component.updateRequest = of(null);
+ component.saving$ = of(false);
fixture.detectChanges();
});
@@ -280,6 +284,11 @@ describe('EditParameterContext', () => {
expect(component.cancelUpdateRequest).toBeInstanceOf(EventEmitter);
});
+ it('should have continuePostUpdateNavigation EventEmitter', () => {
+ expect(component.continuePostUpdateNavigation).toBeDefined();
+
expect(component.continuePostUpdateNavigation).toBeInstanceOf(EventEmitter);
+ });
+
it('should emit cancelUpdateRequest when called', () => {
const spy = vi.spyOn(component.cancelUpdateRequest, 'emit');
@@ -288,6 +297,136 @@ describe('EditParameterContext', () => {
expect(spy).toHaveBeenCalledTimes(1);
});
+ describe('submitForm', () => {
+ it('should include postUpdateNavigation fields on editParameterContext
emit', () => {
+ const spy = vi.spyOn(component.editParameterContext, 'next');
+ component.editParameterContextForm.markAsDirty();
+
+ component.submitForm(['/parameter-contexts', 'inherited-id',
'edit'], ['/parameter-contexts'], {
+ highlightedParameterName: 'inherited-param'
+ });
+
+ expect(spy).toHaveBeenCalledWith(
+ expect.objectContaining({
+ payload: expect.objectContaining({
+ id: data.parameterContext!.id
+ }),
+ postUpdateNavigation: ['/parameter-contexts',
'inherited-id', 'edit'],
+ postUpdateNavigationBoundary: ['/parameter-contexts'],
+ postUpdateNavigationState: { highlightedParameterName:
'inherited-param' }
+ })
+ );
+ });
+
+ it('should omit postUpdateNavigation fields when not provided', () => {
+ const spy = vi.spyOn(component.editParameterContext, 'next');
+ component.editParameterContextForm.markAsDirty();
+
+ component.submitForm();
+
+ expect(spy).toHaveBeenCalledWith(
+ expect.objectContaining({
+ payload: expect.objectContaining({
+ id: data.parameterContext!.id
+ }),
+ postUpdateNavigation: undefined,
+ postUpdateNavigationBoundary: undefined,
+ postUpdateNavigationState: undefined
+ })
+ );
+ });
+ });
+
+ describe('post-update review actions', () => {
+ const completeUpdateRequest: ParameterContextUpdateRequestEntity = {
+ parameterContextRevision: { version: 1 },
+ request: {
+ complete: true,
+ lastUpdated: '2024-01-01T00:00:00.000Z',
+ percentComponent: 100,
+ referencingComponents: [],
+ requestId: 'request-1',
+ state: 'COMPLETE',
+ updateSteps: [],
+ uri: '/nifi-api/parameter-contexts/update-requests/request-1'
+ }
+ };
+
+ async function createReviewFixture(hasPendingNavigation: boolean):
Promise<void> {
+ TestBed.resetTestingModule();
+ await TestBed.configureTestingModule({
+ imports: [EditParameterContext, NoopAnimationsModule],
+ providers: [
+ { provide: MAT_DIALOG_DATA, useValue: data },
+ provideMockStore({
+ initialState: {
+ [errorFeatureKey]: initialErrorState,
+ [currentUserFeatureKey]: initialCurrentUserState,
+ [parameterContextsFeatureKey]: {
+ [parameterContextListingFeatureKey]:
initialState
+ }
+ }
+ }),
+ {
+ provide: ClusterConnectionService,
+ useValue: {
+ isDisconnectionAcknowledged: vi.fn()
+ }
+ },
+ { provide: MatDialogRef, useValue: null }
+ ]
+ }).compileComponents();
+
+ fixture = TestBed.createComponent(EditParameterContext);
+ component = fixture.componentInstance;
+ component.availableParameterContexts$ = of(parameterContexts);
+ component.hasPendingPostUpdateNavigation$ =
of(hasPendingNavigation);
+ component.updateRequest = of(completeUpdateRequest);
+ component.saving$ = of(false);
+ fixture.detectChanges();
+ }
+
+ it('should show Close as secondary and Go to Parameter as primary when
navigation is pending', async () => {
+ await createReviewFixture(true);
+
+ const closeButton =
fixture.debugElement.query(By.css('button[data-qa="edit-parameter-context-close"]'));
+ const goToButton = fixture.debugElement.query(
+
By.css('button[data-qa="edit-parameter-context-go-to-parameter"]')
+ );
+
+ expect(closeButton).toBeTruthy();
+ expect(closeButton.attributes['mat-button']).toBeDefined();
+ expect(goToButton).toBeTruthy();
+ expect(goToButton.attributes['mat-flat-button']).toBeDefined();
+ });
+
+ it('should emit continuePostUpdateNavigation when Go to Parameter is
clicked', async () => {
+ await createReviewFixture(true);
+
+ const emitSpy = vi.spyOn(component.continuePostUpdateNavigation,
'emit');
+ const goToButton = fixture.debugElement.query(
+
By.css('button[data-qa="edit-parameter-context-go-to-parameter"]')
+ );
+
+ goToButton.nativeElement.click();
+
+ expect(emitSpy).toHaveBeenCalledTimes(1);
+ });
+
+ it('should show only Close as primary when no post-update navigation
is pending', async () => {
+ await createReviewFixture(false);
+
+ const closeButton =
fixture.debugElement.query(By.css('button[data-qa="edit-parameter-context-close"]'));
+ const goToButton = fixture.debugElement.query(
+
By.css('button[data-qa="edit-parameter-context-go-to-parameter"]')
+ );
+
+ expect(closeButton).toBeTruthy();
+ expect(closeButton.attributes['mat-flat-button']).toBeDefined();
+ expect(goToButton).toBeNull();
+ });
+ });
+
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 59e3e75279d..b2d8e1f647c 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
@@ -25,7 +25,7 @@ import { AsyncPipe } from '@angular/common';
import { MatTabsModule } from '@angular/material/tabs';
import { MatOptionModule } from '@angular/material/core';
import { MatSelectModule } from '@angular/material/select';
-import { Observable } from 'rxjs';
+import { Observable, of } from 'rxjs';
import { EditParameterContextRequest } from
'../../../../pages/parameter-contexts/state/parameter-context-listing';
import { Client } from '../../../../service/client.service';
import { ParameterTable } from
'../../../../pages/parameter-contexts/ui/parameter-context-listing/parameter-table/parameter-table.component';
@@ -35,8 +35,10 @@ import {
ParameterContextEntity,
ParameterContextUpdateRequestEntity,
ParameterEntity,
- ParameterProviderConfiguration
+ ParameterProviderConfiguration,
+ PostUpdateNavigationState
} from '../../../../state/shared';
+import { EditParameterContextUpdate } from '../index';
import { ProcessGroupReferences } from
'../../process-group-references/process-group-references.component';
import { ParameterContextInheritance } from
'../parameter-context-inheritance/parameter-context-inheritance.component';
import { ParameterReferences } from
'../../parameter-references/parameter-references.component';
@@ -93,13 +95,17 @@ export class EditParameterContext extends TabbedDialog {
@Input() createNewParameter!: (existingParameters: string[]) =>
Observable<EditParameterResponse>;
@Input() editParameter!: (parameter: Parameter) =>
Observable<EditParameterResponse>;
+ @Input() goToParameter?: (parameterContextId: string, parameterName:
string) => void;
@Input() updateRequest!: Observable<ParameterContextUpdateRequestEntity |
null>;
@Input() availableParameterContexts$!:
Observable<ParameterContextEntity[]>;
@Input() saving$!: Observable<boolean>;
+ @Input() hasPendingPostUpdateNavigation$: Observable<boolean> = of(false);
@Output() addParameterContext: EventEmitter<any> = new EventEmitter<any>();
- @Output() editParameterContext: EventEmitter<any> = new
EventEmitter<any>();
- @Output() cancelUpdateRequest: EventEmitter<any> = new EventEmitter<any>();
+ @Output() editParameterContext: EventEmitter<EditParameterContextUpdate> =
+ new EventEmitter<EditParameterContextUpdate>();
+ @Output() cancelUpdateRequest: EventEmitter<void> = new
EventEmitter<void>();
+ @Output() continuePostUpdateNavigation: EventEmitter<void> = new
EventEmitter<void>();
editParameterContextForm: FormGroup;
readonly: boolean;
@@ -165,7 +171,11 @@ export class EditParameterContext extends TabbedDialog {
return false;
}
- submitForm() {
+ submitForm(
+ postUpdateNavigation?: string[],
+ postUpdateNavigationBoundary?: string[],
+ postUpdateNavigationState?: PostUpdateNavigationState
+ ) {
if (this.isNew) {
const payload: any = {
revision: {
@@ -215,7 +225,12 @@ export class EditParameterContext extends TabbedDialog {
}
};
- this.editParameterContext.next(payload);
+ this.editParameterContext.next({
+ payload,
+ postUpdateNavigation,
+ postUpdateNavigationBoundary,
+ postUpdateNavigationState
+ });
}
}
diff --git
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/parameter-context/index.ts
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/parameter-context/index.ts
index 4d4847b7795..24924274725 100644
---
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/parameter-context/index.ts
+++
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/parameter-context/index.ts
@@ -15,10 +15,13 @@
* limitations under the License.
*/
-import { ParameterContextEntity } from '../../../state/shared';
+import { ParameterContextEntity, PostUpdateNavigationState } from
'../../../state/shared';
-export interface EditParameterContextRequest {
- parameterContext?: ParameterContextEntity;
+export interface EditParameterContextUpdate {
+ payload: any;
+ postUpdateNavigation?: string[];
+ postUpdateNavigationBoundary?: string[];
+ postUpdateNavigationState?: PostUpdateNavigationState;
}
export interface CreateParameterContextRequest {
diff --git
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/parameter-context/save-parameter-context-changes-dialog/save-parameter-context-changes-dialog.component.html
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/parameter-context/save-parameter-context-changes-dialog/save-parameter-context-changes-dialog.component.html
new file mode 100644
index 00000000000..c46c61e291c
--- /dev/null
+++
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/parameter-context/save-parameter-context-changes-dialog/save-parameter-context-changes-dialog.component.html
@@ -0,0 +1,82 @@
+<!--
+ ~ 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.
+ -->
+
+<h2 mat-dialog-title data-qa="save-parameter-context-changes-title">Unsaved
Changes</h2>
+<mat-dialog-content>
+ @if (request.canSave) {
+ <div class="text-base"
data-qa="save-parameter-context-changes-message">
+ This Parameter Context has unsaved changes. Save them before going
to this {{ request.destination }}?
+ Choosing "Don't Save" will discard all pending changes.
+ </div>
+ } @else {
+ <div class="text-base"
data-qa="save-parameter-context-changes-message">
+ This Parameter Context has unsaved changes, but they can't be
saved because the form is not in a saveable
+ state. Choose "Don't Save" to discard all pending changes and
continue, or "Cancel" to keep editing.
+ </div>
+ }
+</mat-dialog-content>
+<mat-dialog-actions align="end">
+ <button
+ mat-button
+ type="button"
+ mat-dialog-close
+ aria-label="Cancel"
+ data-qa="save-parameter-context-changes-cancel-button">
+ Cancel
+ </button>
+ @if (request.canSave) {
+ <button
+ mat-button
+ type="button"
+ mat-dialog-close
+ aria-label="Don't Save"
+ (click)="discardClicked()"
+ data-qa="save-parameter-context-changes-discard-button">
+ Don't Save
+ </button>
+ <button
+ mat-flat-button
+ cdkFocusInitial
+ type="button"
+ mat-dialog-close
+ aria-label="Save"
+ (click)="saveClicked()"
+ data-qa="save-parameter-context-changes-save-button">
+ Save
+ </button>
+ } @else {
+ <button
+ mat-button
+ cdkFocusInitial
+ type="button"
+ mat-dialog-close
+ aria-label="Don't Save"
+ (click)="discardClicked()"
+ data-qa="save-parameter-context-changes-discard-button">
+ Don't Save
+ </button>
+ <button
+ mat-flat-button
+ type="button"
+ mat-dialog-close
+ disabled
+ aria-label="Save"
+ data-qa="save-parameter-context-changes-save-button">
+ Save
+ </button>
+ }
+</mat-dialog-actions>
diff --git
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/parameter-context/save-parameter-context-changes-dialog/save-parameter-context-changes-dialog.component.spec.ts
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/parameter-context/save-parameter-context-changes-dialog/save-parameter-context-changes-dialog.component.spec.ts
new file mode 100644
index 00000000000..4d6842837e3
--- /dev/null
+++
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/parameter-context/save-parameter-context-changes-dialog/save-parameter-context-changes-dialog.component.spec.ts
@@ -0,0 +1,110 @@
+/*
+ * 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 { ComponentFixture, TestBed } from '@angular/core/testing';
+import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
+import { By } from '@angular/platform-browser';
+
+import {
+ SaveParameterContextChangesDialog,
+ SaveParameterContextChangesDialogRequest
+} from './save-parameter-context-changes-dialog.component';
+
+describe('SaveParameterContextChangesDialog', () => {
+ let component: SaveParameterContextChangesDialog;
+ let fixture: ComponentFixture<SaveParameterContextChangesDialog>;
+
+ function setup(data: SaveParameterContextChangesDialogRequest) {
+ TestBed.configureTestingModule({
+ imports: [SaveParameterContextChangesDialog],
+ providers: [
+ { provide: MAT_DIALOG_DATA, useValue: data },
+ { provide: MatDialogRef, useValue: null }
+ ]
+ });
+ fixture = TestBed.createComponent(SaveParameterContextChangesDialog);
+ component = fixture.componentInstance;
+ fixture.detectChanges();
+ }
+
+ afterEach(() => {
+ TestBed.resetTestingModule();
+ });
+
+ it('should create', () => {
+ setup({ destination: 'Parameter', canSave: true });
+ expect(component).toBeTruthy();
+ });
+
+ it('should emit when save clicked', () => {
+ setup({ destination: 'Parameter', canSave: true });
+ const emitSpy = vi.spyOn(component.save, 'next');
+ component.saveClicked();
+ expect(emitSpy).toHaveBeenCalled();
+ });
+
+ it('should emit when discard clicked', () => {
+ setup({ destination: 'Parameter', canSave: true });
+ const emitSpy = vi.spyOn(component.discard, 'next');
+ component.discardClicked();
+ expect(emitSpy).toHaveBeenCalled();
+ });
+
+ it('should enable Save, focus Save, and mention discarding pending changes
when the form can be saved', () => {
+ setup({ destination: 'Parameter', canSave: true });
+
+ const saveButton = fixture.debugElement.query(
+
By.css('button[data-qa="save-parameter-context-changes-save-button"]')
+ );
+ const discardButton = fixture.debugElement.query(
+
By.css('button[data-qa="save-parameter-context-changes-discard-button"]')
+ );
+ const message =
fixture.debugElement.query(By.css('div[data-qa="save-parameter-context-changes-message"]'));
+
+ expect(saveButton.nativeElement.disabled).toBe(false);
+ expect(saveButton.attributes['cdkFocusInitial']).toBeDefined();
+ expect(discardButton.attributes['cdkFocusInitial']).toBeUndefined();
+ expect(message.nativeElement.textContent).toContain('discard all
pending changes');
+ });
+
+ it("should disable Save, focus Don't Save, and indicate the form is not
saveable when the form cannot be saved", () => {
+ setup({ destination: 'Parameter', canSave: false });
+
+ const saveButton = fixture.debugElement.query(
+
By.css('button[data-qa="save-parameter-context-changes-save-button"]')
+ );
+ const discardButton = fixture.debugElement.query(
+
By.css('button[data-qa="save-parameter-context-changes-discard-button"]')
+ );
+ const message =
fixture.debugElement.query(By.css('div[data-qa="save-parameter-context-changes-message"]'));
+
+ expect(saveButton.nativeElement.disabled).toBe(true);
+ expect(discardButton.attributes['cdkFocusInitial']).toBeDefined();
+ expect(saveButton.attributes['cdkFocusInitial']).toBeUndefined();
+ expect(message.nativeElement.textContent).toContain('not in a
saveable');
+ });
+
+ it('should always render a Cancel button that only closes the dialog', ()
=> {
+ setup({ destination: 'Parameter', canSave: false });
+
+ const cancelButton = fixture.debugElement.query(
+
By.css('button[data-qa="save-parameter-context-changes-cancel-button"]')
+ );
+ expect(cancelButton).toBeTruthy();
+ expect(cancelButton.attributes['mat-dialog-close']).toBeDefined();
+ });
+});
diff --git
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/parameter-context/save-parameter-context-changes-dialog/save-parameter-context-changes-dialog.component.ts
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/parameter-context/save-parameter-context-changes-dialog/save-parameter-context-changes-dialog.component.ts
new file mode 100644
index 00000000000..08c587fe000
--- /dev/null
+++
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/parameter-context/save-parameter-context-changes-dialog/save-parameter-context-changes-dialog.component.ts
@@ -0,0 +1,46 @@
+/*
+ * 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 { Component, EventEmitter, Output, inject } from '@angular/core';
+import { MAT_DIALOG_DATA, MatDialogModule } from '@angular/material/dialog';
+import { MatButtonModule } from '@angular/material/button';
+import { CloseOnEscapeDialog } from '@nifi/shared';
+
+export interface SaveParameterContextChangesDialogRequest {
+ destination: string;
+ canSave: boolean;
+}
+
+@Component({
+ selector: 'save-parameter-context-changes-dialog',
+ imports: [MatDialogModule, MatButtonModule],
+ templateUrl: './save-parameter-context-changes-dialog.component.html'
+})
+export class SaveParameterContextChangesDialog extends CloseOnEscapeDialog {
+ request =
inject<SaveParameterContextChangesDialogRequest>(MAT_DIALOG_DATA);
+
+ @Output() save: EventEmitter<void> = new EventEmitter<void>();
+ @Output() discard: EventEmitter<void> = new EventEmitter<void>();
+
+ saveClicked(): void {
+ this.save.next();
+ }
+
+ discardClicked(): void {
+ this.discard.next();
+ }
+}
diff --git a/nifi-frontend/src/main/frontend/nx.json
b/nifi-frontend/src/main/frontend/nx.json
index f20867cbbc9..6a36ce76177 100644
--- a/nifi-frontend/src/main/frontend/nx.json
+++ b/nifi-frontend/src/main/frontend/nx.json
@@ -121,5 +121,6 @@
"@schematics/angular:resolver": {
"typeSeparator": "."
}
- }
+ },
+ "analytics": false
}