Skip to content

Commit 2fa3286

Browse files
authored
NIFI-15822: Adding support to view read only configuration of connectors in the connector canvas. (#11168)
- Moving all configuration dialogs into top level ui components. This closes #11168
1 parent d8a2453 commit 2fa3286

119 files changed

Lines changed: 1435 additions & 1115 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/state/connector-canvas/connector-canvas.actions.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717

1818
import { createAction, props } from '@ngrx/store';
1919
import { ComponentType } from '@nifi/shared';
20-
import { BreadcrumbEntity } from '../../../flow-designer/state/shared';
20+
import { BreadcrumbEntity } from '../../../../state/shared';
2121
import { ErrorContext } from '../../../../state/error';
2222

2323
/**
@@ -107,4 +107,13 @@ export const navigateToProvenanceForComponent = createAction(
107107
props<{ id: string; componentType: ComponentType }>()
108108
);
109109

110+
/**
111+
* View the read-only configuration of a canvas component.
112+
* Triggers an effect that opens the appropriate EditXyz dialog in read-only mode.
113+
*/
114+
export const viewComponentConfiguration = createAction(
115+
'[Connector Canvas] View Component Configuration',
116+
props<{ request: { entity: any; componentType: ComponentType } }>()
117+
);
118+
110119
export const resetConnectorCanvasState = createAction('[Connector Canvas] Reset State');

nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/state/connector-canvas/connector-canvas.effects.spec.ts

Lines changed: 113 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import { Router } from '@angular/router';
2222
import { firstValueFrom, Observable, of, Subject, throwError } from 'rxjs';
2323
import { HttpErrorResponse } from '@angular/common/http';
2424
import { ComponentType, ComponentTypeNamePipe } from '@nifi/shared';
25+
import { MatDialog } from '@angular/material/dialog';
2526
import { ConnectorCanvasEffects } from './connector-canvas.effects';
2627
import { ConnectorService } from '../../service/connector.service';
2728
import { ErrorHelper } from '../../../../service/error-helper.service';
@@ -37,7 +38,8 @@ import {
3738
loadConnectorFlowSuccess,
3839
navigateToProvenanceForComponent,
3940
navigateWithoutTransform,
40-
selectComponents
41+
selectComponents,
42+
viewComponentConfiguration
4143
} from './connector-canvas.actions';
4244
import {
4345
selectConnectorIdFromRoute,
@@ -80,6 +82,10 @@ describe('ConnectorCanvasEffects', () => {
8082
navigate: vi.fn().mockResolvedValue(true)
8183
};
8284

85+
const mockDialog = {
86+
open: vi.fn().mockReturnValue({ componentInstance: {} })
87+
};
88+
8389
await TestBed.configureTestingModule({
8490
providers: [
8591
ConnectorCanvasEffects,
@@ -96,6 +102,7 @@ describe('ConnectorCanvasEffects', () => {
96102
{ provide: ConnectorService, useValue: mockConnectorService },
97103
{ provide: ErrorHelper, useValue: mockErrorHelper },
98104
{ provide: Router, useValue: mockRouter },
105+
{ provide: MatDialog, useValue: mockDialog },
99106
ComponentTypeNamePipe
100107
]
101108
}).compileComponents();
@@ -111,7 +118,8 @@ describe('ConnectorCanvasEffects', () => {
111118
},
112119
mockConnectorService,
113120
mockErrorHelper,
114-
mockRouter
121+
mockRouter,
122+
mockDialog
115123
};
116124
}
117125

@@ -424,6 +432,109 @@ describe('ConnectorCanvasEffects', () => {
424432
});
425433
});
426434

435+
describe('viewComponentConfiguration$', () => {
436+
const baseEntity = {
437+
id: 'comp-1',
438+
uri: 'https://localhost/nifi-api/processors/comp-1',
439+
permissions: { canRead: true, canWrite: true },
440+
operatePermissions: { canRead: true, canWrite: true },
441+
component: { name: 'My Component' }
442+
};
443+
444+
async function dispatchView(componentType: ComponentType, entity: any = baseEntity) {
445+
const { effects, actions$, mockDialog } = await setup();
446+
actions$(of(viewComponentConfiguration({ request: { entity, componentType } })));
447+
await firstValueFrom(effects.viewComponentConfiguration$);
448+
return { mockDialog };
449+
}
450+
451+
it('opens the processor dialog with read-only permissions and the entity uri', async () => {
452+
const { mockDialog } = await dispatchView(ComponentType.Processor);
453+
expect(mockDialog.open).toHaveBeenCalledTimes(1);
454+
const [, config] = (mockDialog.open as Mock).mock.calls[0];
455+
expect(config.data.type).toBe(ComponentType.Processor);
456+
expect(config.data.uri).toBe(baseEntity.uri);
457+
expect(config.data.entity.permissions.canWrite).toBe(false);
458+
expect(config.data.entity.permissions.canRead).toBe(true);
459+
expect(config.data.entity.operatePermissions.canWrite).toBe(false);
460+
expect(config.data.entity.operatePermissions.canRead).toBe(true);
461+
expect(config.id).toBe(baseEntity.id);
462+
});
463+
464+
it('opens the connection dialog with the breadcrumbs observable wired', async () => {
465+
const { effects, actions$, mockDialog } = await setup();
466+
const componentInstance: any = {};
467+
(mockDialog.open as Mock).mockReturnValue({ componentInstance });
468+
actions$(
469+
of(
470+
viewComponentConfiguration({
471+
request: { entity: baseEntity, componentType: ComponentType.Connection }
472+
})
473+
)
474+
);
475+
await firstValueFrom(effects.viewComponentConfiguration$);
476+
477+
const [, config] = (mockDialog.open as Mock).mock.calls[0];
478+
expect(config.data.type).toBe(ComponentType.Connection);
479+
expect(componentInstance.breadcrumbs$).toBeDefined();
480+
expect(componentInstance.availablePrioritizers$).toBeDefined();
481+
expect(componentInstance.getChildInputPorts).toBeInstanceOf(Function);
482+
expect(componentInstance.getChildOutputPorts).toBeInstanceOf(Function);
483+
});
484+
485+
it('opens the input port dialog with read-only permissions', async () => {
486+
const { mockDialog } = await dispatchView(ComponentType.InputPort);
487+
const [, config] = (mockDialog.open as Mock).mock.calls[0];
488+
expect(config.data.type).toBe(ComponentType.InputPort);
489+
expect(config.data.entity.permissions.canWrite).toBe(false);
490+
});
491+
492+
it('opens the output port dialog with read-only permissions', async () => {
493+
const { mockDialog } = await dispatchView(ComponentType.OutputPort);
494+
const [, config] = (mockDialog.open as Mock).mock.calls[0];
495+
expect(config.data.type).toBe(ComponentType.OutputPort);
496+
expect(config.data.entity.permissions.canWrite).toBe(false);
497+
});
498+
499+
it('opens the label dialog with read-only permissions', async () => {
500+
const { mockDialog } = await dispatchView(ComponentType.Label);
501+
const [, config] = (mockDialog.open as Mock).mock.calls[0];
502+
expect(config.data.type).toBe(ComponentType.Label);
503+
expect(config.data.entity.permissions.canWrite).toBe(false);
504+
});
505+
506+
it('opens the process group dialog with the current user observable wired', async () => {
507+
const { effects, actions$, mockDialog } = await setup();
508+
const componentInstance: any = {};
509+
(mockDialog.open as Mock).mockReturnValue({ componentInstance });
510+
actions$(
511+
of(
512+
viewComponentConfiguration({
513+
request: { entity: baseEntity, componentType: ComponentType.ProcessGroup }
514+
})
515+
)
516+
);
517+
await firstValueFrom(effects.viewComponentConfiguration$);
518+
519+
const [, config] = (mockDialog.open as Mock).mock.calls[0];
520+
expect(config.data.type).toBe(ComponentType.ProcessGroup);
521+
expect(componentInstance.currentUser$).toBeDefined();
522+
expect(componentInstance.parameterContexts).toEqual([]);
523+
});
524+
525+
it('opens the remote process group dialog with read-only permissions', async () => {
526+
const { mockDialog } = await dispatchView(ComponentType.RemoteProcessGroup);
527+
const [, config] = (mockDialog.open as Mock).mock.calls[0];
528+
expect(config.data.type).toBe(ComponentType.RemoteProcessGroup);
529+
expect(config.data.entity.permissions.canWrite).toBe(false);
530+
});
531+
532+
it('does not open a dialog for unsupported component types', async () => {
533+
const { mockDialog } = await dispatchView(ComponentType.Funnel);
534+
expect(mockDialog.open).not.toHaveBeenCalled();
535+
});
536+
});
537+
427538
describe('navigateToProvenanceForComponent$', () => {
428539
it('should navigate to /provenance with componentId query param and back navigation state', async () => {
429540
const { effects, actions$, mockRouter } = await setup({

nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/state/connector-canvas/connector-canvas.effects.ts

Lines changed: 171 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,20 +20,40 @@ import { Actions, createEffect, ofType } from '@ngrx/effects';
2020
import { concatLatestFrom } from '@ngrx/operators';
2121
import { Store } from '@ngrx/store';
2222
import { Router } from '@angular/router';
23-
import { of } from 'rxjs';
23+
import { NEVER, Observable, of } from 'rxjs';
2424
import { catchError, filter, map, switchMap, take, tap } from 'rxjs/operators';
25-
import { ComponentType, ComponentTypeNamePipe } from '@nifi/shared';
25+
import { ComponentType, ComponentTypeNamePipe, LARGE_DIALOG, MEDIUM_DIALOG, XL_DIALOG } from '@nifi/shared';
26+
import { selectCurrentUser } from '../../../../state/current-user/current-user.selectors';
27+
import { selectPrioritizerTypes } from '../../../../state/extension-types/extension-types.selectors';
28+
import {
29+
selectPropertyVerificationResults,
30+
selectPropertyVerificationStatus
31+
} from '../../../../state/property-verification/property-verification.selectors';
32+
import { MatDialog } from '@angular/material/dialog';
2633
import { ConnectorService } from '../../service/connector.service';
2734
import { ErrorHelper } from '../../../../service/error-helper.service';
2835
import { ErrorContextKey } from '../../../../state/error';
2936
import { BackNavigation } from '../../../../state/navigation';
37+
import { EditComponentDialogRequest, EditConnectionDialogRequest } from '../../../../state/shared';
38+
import { EditProcessor } from '../../../../ui/common/component-dialogs/edit-processor/edit-processor.component';
39+
import { EditConnectionComponent } from '../../../../ui/common/component-dialogs/edit-connection/edit-connection.component';
40+
import { EditPort } from '../../../../ui/common/component-dialogs/edit-port/edit-port.component';
41+
import { EditLabel } from '../../../../ui/common/component-dialogs/edit-label/edit-label.component';
42+
import { EditProcessGroup } from '../../../../ui/common/component-dialogs/edit-process-group/edit-process-group.component';
43+
import { EditRemoteProcessGroup } from '../../../../ui/common/component-dialogs/edit-remote-process-group/edit-remote-process-group.component';
3044
import * as ConnectorCanvasActions from './connector-canvas.actions';
3145
import { SelectedComponent } from './connector-canvas.actions';
3246
import {
47+
selectBreadcrumbs,
3348
selectConnectorIdFromRoute,
49+
selectInputPort,
50+
selectOutputPort,
3451
selectParentProcessGroupId,
52+
selectProcessGroup,
3553
selectProcessGroupId,
36-
selectProcessGroupIdFromRoute
54+
selectProcessGroupIdFromRoute,
55+
selectProcessor,
56+
selectRemoteProcessGroup
3757
} from './connector-canvas.selectors';
3858

3959
@Injectable()
@@ -44,6 +64,7 @@ export class ConnectorCanvasEffects {
4464
private connectorService = inject(ConnectorService);
4565
private errorHelper = inject(ErrorHelper);
4666
private componentTypeNamePipe = inject(ComponentTypeNamePipe);
67+
private dialog = inject(MatDialog);
4768

4869
loadConnectorFlow$ = createEffect(() =>
4970
this.actions$.pipe(
@@ -228,6 +249,47 @@ export class ConnectorCanvasEffects {
228249
)
229250
);
230251

252+
/**
253+
* Open a read-only configuration dialog for the given canvas component.
254+
* Forces read-only mode by overriding canWrite regardless of entity permissions
255+
* since the connector canvas is a view-only interface.
256+
*/
257+
viewComponentConfiguration$ = createEffect(
258+
() =>
259+
this.actions$.pipe(
260+
ofType(ConnectorCanvasActions.viewComponentConfiguration),
261+
map((action) => action.request),
262+
tap(({ entity, componentType }) => {
263+
const dialogRequest = this.buildDialogRequest(entity, componentType);
264+
265+
switch (componentType) {
266+
case ComponentType.Processor:
267+
this.openReadOnlyProcessorDialog(dialogRequest);
268+
break;
269+
case ComponentType.Connection:
270+
this.openReadOnlyConnectionDialog(dialogRequest);
271+
break;
272+
case ComponentType.InputPort:
273+
case ComponentType.OutputPort:
274+
this.openReadOnlyPortDialog(dialogRequest);
275+
break;
276+
case ComponentType.Label:
277+
this.openReadOnlyLabelDialog(dialogRequest);
278+
break;
279+
case ComponentType.ProcessGroup:
280+
this.openReadOnlyProcessGroupDialog(dialogRequest);
281+
break;
282+
case ComponentType.RemoteProcessGroup:
283+
this.openReadOnlyRemoteProcessGroupDialog(dialogRequest);
284+
break;
285+
default:
286+
break;
287+
}
288+
})
289+
),
290+
{ dispatch: false }
291+
);
292+
231293
navigateToProvenanceForComponent$ = createEffect(
232294
() =>
233295
this.actions$.pipe(
@@ -259,4 +321,110 @@ export class ConnectorCanvasEffects {
259321
),
260322
{ dispatch: false }
261323
);
324+
325+
/**
326+
* Build a dialog request from the supplied entity. The connector canvas surfaces
327+
* configuration in a strictly read-only mode, so the entity permissions are
328+
* forced to readable-only-without-write before being passed to the dialog.
329+
*/
330+
private buildDialogRequest(entity: any, componentType: ComponentType): EditComponentDialogRequest {
331+
const readOnlyEntity = {
332+
...entity,
333+
permissions: { ...entity?.permissions, canWrite: false },
334+
operatePermissions: { ...entity?.operatePermissions, canWrite: false }
335+
};
336+
return {
337+
type: componentType,
338+
uri: readOnlyEntity.uri,
339+
entity: readOnlyEntity
340+
};
341+
}
342+
343+
private openReadOnlyProcessorDialog(request: EditComponentDialogRequest): void {
344+
const dialogRef = this.dialog.open(EditProcessor, {
345+
...XL_DIALOG,
346+
data: request,
347+
id: request.entity.id
348+
});
349+
const instance = dialogRef.componentInstance;
350+
instance.saving$ = of(false);
351+
instance.propertyVerificationResults$ = this.store.select(selectPropertyVerificationResults);
352+
instance.propertyVerificationStatus$ = this.store.select(selectPropertyVerificationStatus);
353+
354+
// provide no-op stubs for callbacks not needed in read-only mode
355+
instance.createNewProperty = () => NEVER;
356+
instance.createNewService = () => NEVER;
357+
instance.convertToParameter = () => NEVER;
358+
instance.goToParameter = () => undefined;
359+
instance.goToService = () => undefined;
360+
}
361+
362+
private openReadOnlyConnectionDialog(request: EditComponentDialogRequest): void {
363+
const dialogRef = this.dialog.open(EditConnectionComponent, {
364+
...LARGE_DIALOG,
365+
data: request as EditConnectionDialogRequest
366+
});
367+
const instance = dialogRef.componentInstance;
368+
instance.saving$ = of(false);
369+
instance.availablePrioritizers$ = this.store.select(selectPrioritizerTypes);
370+
instance.breadcrumbs$ = this.store.select(selectBreadcrumbs);
371+
instance.getChildOutputPorts = (groupId: string): Observable<any> =>
372+
this.store.select(selectConnectorIdFromRoute).pipe(
373+
take(1),
374+
switchMap((connectorId) =>
375+
this.connectorService
376+
.getConnectorFlow(connectorId!, groupId)
377+
.pipe(map((response: any) => response.processGroupFlow.flow.outputPorts))
378+
)
379+
);
380+
instance.getChildInputPorts = (groupId: string): Observable<any> =>
381+
this.store.select(selectConnectorIdFromRoute).pipe(
382+
take(1),
383+
switchMap((connectorId) =>
384+
this.connectorService
385+
.getConnectorFlow(connectorId!, groupId)
386+
.pipe(map((response: any) => response.processGroupFlow.flow.inputPorts))
387+
)
388+
);
389+
instance.selectProcessor = (id: string) => this.store.select(selectProcessor(id));
390+
instance.selectInputPort = (id: string) => this.store.select(selectInputPort(id));
391+
instance.selectOutputPort = (id: string) => this.store.select(selectOutputPort(id));
392+
instance.selectProcessGroup = (id: string) => this.store.select(selectProcessGroup(id));
393+
instance.selectRemoteProcessGroup = (id: string) => this.store.select(selectRemoteProcessGroup(id));
394+
}
395+
396+
private openReadOnlyPortDialog(request: EditComponentDialogRequest): void {
397+
const dialogRef = this.dialog.open(EditPort, {
398+
...MEDIUM_DIALOG,
399+
data: request
400+
});
401+
dialogRef.componentInstance.saving$ = of(false);
402+
}
403+
404+
private openReadOnlyLabelDialog(request: EditComponentDialogRequest): void {
405+
const dialogRef = this.dialog.open(EditLabel, {
406+
...MEDIUM_DIALOG,
407+
data: request
408+
});
409+
dialogRef.componentInstance.saving$ = of(false);
410+
}
411+
412+
private openReadOnlyProcessGroupDialog(request: EditComponentDialogRequest): void {
413+
const dialogRef = this.dialog.open(EditProcessGroup, {
414+
...LARGE_DIALOG,
415+
data: request
416+
});
417+
const instance = dialogRef.componentInstance;
418+
instance.saving$ = of(false);
419+
instance.currentUser$ = this.store.select(selectCurrentUser);
420+
instance.parameterContexts = [];
421+
}
422+
423+
private openReadOnlyRemoteProcessGroupDialog(request: EditComponentDialogRequest): void {
424+
const dialogRef = this.dialog.open(EditRemoteProcessGroup, {
425+
...LARGE_DIALOG,
426+
data: request
427+
});
428+
dialogRef.componentInstance.saving$ = of(false);
429+
}
262430
}

nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/state/connector-canvas/connector-canvas.selectors.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,5 +96,8 @@ export const selectConnection = (id: string) =>
9696
export const selectRemoteProcessGroup = (id: string) =>
9797
createSelector(selectRemoteProcessGroups, (rpgs) => rpgs.find((r: any) => r.id === id));
9898

99+
export const selectProcessGroup = (id: string) =>
100+
createSelector(selectProcessGroups, (pgs) => pgs.find((pg: any) => pg.id === id));
101+
99102
export const selectFunnel = (id: string) =>
100103
createSelector(selectFunnels, (funnels) => funnels.find((f: any) => f.id === id));

0 commit comments

Comments
 (0)