This is an automated email from the ASF dual-hosted git repository.
rfellows 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 edaaf02498d NIFI-15987: Updating the global and connector specific
policy managem… (#11304)
edaaf02498d is described below
commit edaaf02498db96cdf6213b47859f77f9c4f99b20
Author: Matt Gilman <[email protected]>
AuthorDate: Wed Jun 3 16:21:12 2026 -0400
NIFI-15987: Updating the global and connector specific policy managem…
(#11304)
* NIFI-15987: Updating the global and connector specific policy management
pages to include policies for view/write data and viewing provenance events.
* NIFI-15987: Addressing review feedback.
* NIFI-15987: Using tertiary color instead of primary to align with other
component icons.
* NIFI-15987: Addressing additional review feedback.
* NIFI-15987: Addressing additional review feedback.
---
.../service/access-policy.service.spec.ts | 178 +++++++++++++
.../service/access-policy.service.ts | 49 +++-
.../access-policy/access-policy.effects.spec.ts | 275 +++++++++++++++++++++
.../state/access-policy/access-policy.effects.ts | 5 +-
.../component-access-policies.component.html | 21 ++
.../component-access-policies.component.spec.ts | 105 ++++++++
.../component-access-policies.component.ts | 26 +-
.../global-access-policies.component.html | 38 ++-
.../global-access-policies.component.spec.ts | 264 +++++++++++++++++++-
.../global-access-policies.component.ts | 159 ++++++++++--
.../queue-listing/queue-listing.effects.spec.ts | 202 +++++++++++++++
.../state/queue-listing/queue-listing.effects.ts | 42 ++--
.../user-access-policies.component.spec.ts | 123 +++++++++
.../user-access-policies.component.ts | 27 ++
.../component-context.component.html | 8 +-
.../component-context.component.spec.ts | 18 +-
.../component-context.component.ts | 35 ++-
.../shared/src/pipes/component-type-name.pipe.ts | 2 +
18 files changed, 1495 insertions(+), 82 deletions(-)
diff --git
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/access-policies/service/access-policy.service.spec.ts
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/access-policies/service/access-policy.service.spec.ts
new file mode 100644
index 00000000000..732d18f5622
--- /dev/null
+++
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/access-policies/service/access-policy.service.spec.ts
@@ -0,0 +1,178 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { TestBed } from '@angular/core/testing';
+import { provideHttpClient } from '@angular/common/http';
+import { HttpTestingController, provideHttpClientTesting } from
'@angular/common/http/testing';
+import { AccessPolicyService } from './access-policy.service';
+import { Action, ResourceAction } from '../state/shared';
+import { Client } from '../../../service/client.service';
+import { ClusterConnectionService } from
'../../../service/cluster-connection.service';
+
+describe('AccessPolicyService', () => {
+ let service: AccessPolicyService;
+
+ beforeEach(() => {
+ TestBed.configureTestingModule({
+ providers: [
+ AccessPolicyService,
+ provideHttpClient(),
+ provideHttpClientTesting(),
+ {
+ provide: Client,
+ useValue: {
+ getClientId: () => 'client-id',
+ getRevision: (entity: { revision: unknown }) =>
entity.revision
+ }
+ },
+ {
+ provide: ClusterConnectionService,
+ useValue: { isDisconnectionAcknowledged: () => false }
+ }
+ ]
+ });
+
+ service = TestBed.inject(AccessPolicyService);
+ });
+
+ describe('buildResourcePath', () => {
+ function buildPath(resourceAction: ResourceAction): string {
+ return service.buildResourcePath(resourceAction);
+ }
+
+ it('should transform connectors data policy to /data/connectors', ()
=> {
+ expect(
+ buildPath({
+ resource: 'connectors',
+ resourceIdentifier: 'data',
+ action: Action.Read
+ })
+ ).toBe('/data/connectors');
+ });
+
+ it('should transform connectors provenance-data policy to
/provenance-data/connectors', () => {
+ expect(
+ buildPath({
+ resource: 'connectors',
+ resourceIdentifier: 'provenance-data',
+ action: Action.Read
+ })
+ ).toBe('/provenance-data/connectors');
+ });
+
+ it('should build global connectors resource path', () => {
+ expect(
+ buildPath({
+ resource: 'connectors',
+ action: Action.Read
+ })
+ ).toBe('/connectors');
+ });
+
+ it('should build per-instance connector resource path', () => {
+ expect(
+ buildPath({
+ resource: 'connectors',
+ resourceIdentifier: 'connector-1',
+ action: Action.Write
+ })
+ ).toBe('/connectors/connector-1');
+ });
+
+ it('should pass through non-connector resources unchanged', () => {
+ expect(
+ buildPath({
+ resource: 'processors',
+ resourceIdentifier: 'processor-1',
+ action: Action.Read
+ })
+ ).toBe('/processors/processor-1');
+ });
+ });
+
+ describe('createAccessPolicy', () => {
+ it('should POST with canonical /data/connectors resource', () => {
+ const httpMock = TestBed.inject(HttpTestingController);
+
+ service
+ .createAccessPolicy({
+ resource: 'connectors',
+ resourceIdentifier: 'data',
+ action: Action.Read
+ })
+ .subscribe();
+
+ const req = httpMock.expectOne('../nifi-api/policies');
+ expect(req.request.method).toBe('POST');
+
expect(req.request.body.component.resource).toBe('/data/connectors');
+
expect(req.request.body.component.resourceIdentifier).toBeUndefined();
+ req.flush({});
+ httpMock.verify();
+ });
+
+ it('should POST with canonical /provenance-data/connectors resource',
() => {
+ const httpMock = TestBed.inject(HttpTestingController);
+
+ service
+ .createAccessPolicy({
+ resource: 'connectors',
+ resourceIdentifier: 'provenance-data',
+ action: Action.Read
+ })
+ .subscribe();
+
+ const req = httpMock.expectOne('../nifi-api/policies');
+ expect(req.request.method).toBe('POST');
+
expect(req.request.body.component.resource).toBe('/provenance-data/connectors');
+
expect(req.request.body.component.resourceIdentifier).toBeUndefined();
+ req.flush({});
+ httpMock.verify();
+ });
+ });
+
+ describe('getAccessPolicy', () => {
+ it('should GET the canonical /data/connectors URL', () => {
+ const httpMock = TestBed.inject(HttpTestingController);
+
+ service
+ .getAccessPolicy({
+ resource: 'connectors',
+ resourceIdentifier: 'data',
+ action: Action.Read
+ })
+ .subscribe();
+
+
httpMock.expectOne('../nifi-api/policies/read/data/connectors').flush({});
+ httpMock.verify();
+ });
+
+ it('should GET the canonical /provenance-data/connectors URL', () => {
+ const httpMock = TestBed.inject(HttpTestingController);
+
+ service
+ .getAccessPolicy({
+ resource: 'connectors',
+ resourceIdentifier: 'provenance-data',
+ action: Action.Read
+ })
+ .subscribe();
+
+
httpMock.expectOne('../nifi-api/policies/read/provenance-data/connectors').flush({});
+ httpMock.verify();
+ });
+ });
+});
diff --git
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/access-policies/service/access-policy.service.ts
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/access-policies/service/access-policy.service.ts
index 8c17bfe49a4..40a1fbcd658 100644
---
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/access-policies/service/access-policy.service.ts
+++
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/access-policies/service/access-policy.service.ts
@@ -31,6 +31,43 @@ export class AccessPolicyService {
private static readonly API: string = '../nifi-api';
+ /**
+ * Transforms resourceAction for connector data/provenance policies.
+ * When the UI uses 'connectors' as resource with 'data' or
'provenance-data' as resourceIdentifier,
+ * this transforms it to the API-expected format where 'data' or
'provenance-data' becomes the
+ * resource prefix and 'connectors' becomes the suffix.
+ *
+ * Examples:
+ * - { resource: 'connectors', resourceIdentifier: 'data' } -> { resource:
'data/connectors', resourceIdentifier: undefined }
+ * - { resource: 'connectors', resourceIdentifier: 'provenance-data' } ->
{ resource: 'provenance-data/connectors', resourceIdentifier: undefined }
+ */
+ private transformConnectorPolicyResource(resourceAction: ResourceAction):
ResourceAction {
+ if (
+ resourceAction.resource === 'connectors' &&
+ (resourceAction.resourceIdentifier === 'data' ||
resourceAction.resourceIdentifier === 'provenance-data')
+ ) {
+ return {
+ ...resourceAction,
+ resource: `${resourceAction.resourceIdentifier}/connectors`,
+ resourceIdentifier: undefined
+ };
+ }
+ return resourceAction;
+ }
+
+ /**
+ * Builds the API resource path from a ResourceAction.
+ * Handles connector data/provenance policy transformations.
+ */
+ buildResourcePath(resourceAction: ResourceAction): string {
+ const transformed =
this.transformConnectorPolicyResource(resourceAction);
+ let resource = `/${transformed.resource}`;
+ if (transformed.resourceIdentifier) {
+ resource += `/${transformed.resourceIdentifier}`;
+ }
+ return resource;
+ }
+
createAccessPolicy(
resourceAction: ResourceAction,
{
@@ -41,10 +78,7 @@ export class AccessPolicyService {
users?: TenantEntity[];
} = {}
): Observable<any> {
- let resource = `/${resourceAction.resource}`;
- if (resourceAction.resourceIdentifier) {
- resource += `/${resourceAction.resourceIdentifier}`;
- }
+ const resource = this.buildResourcePath(resourceAction);
const payload: unknown = {
revision: {
@@ -64,9 +98,10 @@ export class AccessPolicyService {
}
getAccessPolicy(resourceAction: ResourceAction): Observable<any> {
- const path: string[] = [resourceAction.action,
resourceAction.resource];
- if (resourceAction.resourceIdentifier) {
- path.push(resourceAction.resourceIdentifier);
+ const transformed =
this.transformConnectorPolicyResource(resourceAction);
+ const path: string[] = [transformed.action, transformed.resource];
+ if (transformed.resourceIdentifier) {
+ path.push(transformed.resourceIdentifier);
}
return
this.httpClient.get(`${AccessPolicyService.API}/policies/${path.join('/')}`);
}
diff --git
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/access-policies/state/access-policy/access-policy.effects.spec.ts
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/access-policies/state/access-policy/access-policy.effects.spec.ts
new file mode 100644
index 00000000000..e14baa33d25
--- /dev/null
+++
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/access-policies/state/access-policy/access-policy.effects.spec.ts
@@ -0,0 +1,275 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { TestBed } from '@angular/core/testing';
+import { provideHttpClient } from '@angular/common/http';
+import { provideHttpClientTesting } from '@angular/common/http/testing';
+import { provideMockActions } from '@ngrx/effects/testing';
+import { provideMockStore } from '@ngrx/store/testing';
+import { Action } from '@ngrx/store';
+import { HttpErrorResponse } from '@angular/common/http';
+import { ReplaySubject, of, take, throwError } from 'rxjs';
+
+import { AccessPolicyEffects } from './access-policy.effects';
+import * as AccessPolicyActions from './access-policy.actions';
+import { AccessPolicyService } from '../../service/access-policy.service';
+import { AccessPolicyEntity, Action as PolicyAction, PolicyStatus,
ResourceAction } from '../shared';
+import { Client } from '../../../../service/client.service';
+import { ClusterConnectionService } from
'../../../../service/cluster-connection.service';
+import { ErrorHelper } from '../../../../service/error-helper.service';
+import { MatDialog } from '@angular/material/dialog';
+import { Router } from '@angular/router';
+
+describe('AccessPolicyEffects', () => {
+ let action$: ReplaySubject<Action>;
+
+ function createAccessPolicyEntity(resource: string): AccessPolicyEntity {
+ return {
+ id: 'policy-1',
+ component: {
+ id: 'policy-1',
+ resource,
+ action: PolicyAction.Read,
+ users: [],
+ userGroups: [],
+ configurable: true
+ },
+ revision: { version: 0, clientId: 'client-id' },
+ uri: '/policies/policy-1',
+ permissions: { canRead: true, canWrite: true },
+ generated: '2026-01-01 00:00:00 UTC'
+ } as AccessPolicyEntity;
+ }
+
+ async function setup() {
+ await TestBed.configureTestingModule({
+ providers: [
+ AccessPolicyEffects,
+ AccessPolicyService,
+ provideHttpClient(),
+ provideHttpClientTesting(),
+ provideMockActions(() => action$),
+ provideMockStore(),
+ {
+ provide: Client,
+ useValue: {
+ getClientId: () => 'client-id',
+ getRevision: (entity: { revision: unknown }) =>
entity.revision
+ }
+ },
+ {
+ provide: ClusterConnectionService,
+ useValue: { isDisconnectionAcknowledged: () => false }
+ },
+ { provide: ErrorHelper, useValue: { getErrorString:
vi.fn().mockReturnValue('error') } },
+ { provide: MatDialog, useValue: { open: vi.fn() } },
+ { provide: Router, useValue: { navigate: vi.fn() } }
+ ]
+ }).compileComponents();
+
+ const effects = TestBed.inject(AccessPolicyEffects);
+ const accessPolicyService = TestBed.inject(AccessPolicyService);
+
+ return { effects, accessPolicyService };
+ }
+
+ async function dispatchLoadAccessPolicy(
+ effects: AccessPolicyEffects,
+ resourceAction: ResourceAction
+ ): Promise<Action> {
+ action$.next(
+ AccessPolicyActions.loadAccessPolicy({
+ request: { resourceAction }
+ })
+ );
+
+ return new Promise((resolve) => {
+ effects.loadAccessPolicy$.pipe(take(1)).subscribe(resolve);
+ });
+ }
+
+ beforeEach(() => {
+ action$ = new ReplaySubject<Action>();
+ });
+
+ it('should create', async () => {
+ const { effects } = await setup();
+ expect(effects).toBeTruthy();
+ });
+
+ describe('loadAccessPolicy$', () => {
+ it('should mark connector data policy as Found when API resource
matches buildResourcePath', async () => {
+ const { effects, accessPolicyService } = await setup();
+ const resourceAction: ResourceAction = {
+ resource: 'connectors',
+ resourceIdentifier: 'data',
+ action: PolicyAction.Read
+ };
+ const accessPolicy = createAccessPolicyEntity('/data/connectors');
+
+ vi.spyOn(accessPolicyService,
'getAccessPolicy').mockReturnValue(of(accessPolicy) as never);
+
+ const result = await dispatchLoadAccessPolicy(effects,
resourceAction);
+
+ expect(result).toEqual(
+ AccessPolicyActions.loadAccessPolicySuccess({
+ response: {
+ accessPolicy,
+ policyStatus: PolicyStatus.Found
+ }
+ })
+ );
+
expect(accessPolicyService.getAccessPolicy).toHaveBeenCalledWith(resourceAction);
+ });
+
+ it('should mark connector data policy as Inherited when API resource
differs from buildResourcePath', async () => {
+ const { effects, accessPolicyService } = await setup();
+ const resourceAction: ResourceAction = {
+ resource: 'connectors',
+ resourceIdentifier: 'data',
+ action: PolicyAction.Read
+ };
+ const accessPolicy = createAccessPolicyEntity('/connectors');
+
+ vi.spyOn(accessPolicyService,
'getAccessPolicy').mockReturnValue(of(accessPolicy) as never);
+
+ const result = await dispatchLoadAccessPolicy(effects,
resourceAction);
+
+ expect(result).toEqual(
+ AccessPolicyActions.loadAccessPolicySuccess({
+ response: {
+ accessPolicy,
+ policyStatus: PolicyStatus.Inherited
+ }
+ })
+ );
+ });
+
+ it('should mark connector provenance policy as Found when API resource
matches buildResourcePath', async () => {
+ const { effects, accessPolicyService } = await setup();
+ const resourceAction: ResourceAction = {
+ resource: 'connectors',
+ resourceIdentifier: 'provenance-data',
+ action: PolicyAction.Read
+ };
+ const accessPolicy =
createAccessPolicyEntity('/provenance-data/connectors');
+
+ vi.spyOn(accessPolicyService,
'getAccessPolicy').mockReturnValue(of(accessPolicy) as never);
+
+ const result = await dispatchLoadAccessPolicy(effects,
resourceAction);
+
+ expect(result).toEqual(
+ AccessPolicyActions.loadAccessPolicySuccess({
+ response: {
+ accessPolicy,
+ policyStatus: PolicyStatus.Found
+ }
+ })
+ );
+ });
+
+ it('should mark global connectors policy as Found when API resource is
/connectors', async () => {
+ const { effects, accessPolicyService } = await setup();
+ const resourceAction: ResourceAction = {
+ resource: 'connectors',
+ action: PolicyAction.Write
+ };
+ const accessPolicy = createAccessPolicyEntity('/connectors');
+
+ vi.spyOn(accessPolicyService,
'getAccessPolicy').mockReturnValue(of(accessPolicy) as never);
+
+ const result = await dispatchLoadAccessPolicy(effects,
resourceAction);
+
+ expect(result).toEqual(
+ AccessPolicyActions.loadAccessPolicySuccess({
+ response: {
+ accessPolicy,
+ policyStatus: PolicyStatus.Found
+ }
+ })
+ );
+ });
+
+ it('should treat untransformed connector data path as Inherited',
async () => {
+ const { effects, accessPolicyService } = await setup();
+ const resourceAction: ResourceAction = {
+ resource: 'connectors',
+ resourceIdentifier: 'data',
+ action: PolicyAction.Read
+ };
+ const accessPolicy = createAccessPolicyEntity('/connectors/data');
+
+ vi.spyOn(accessPolicyService,
'getAccessPolicy').mockReturnValue(of(accessPolicy) as never);
+
+ const result = await dispatchLoadAccessPolicy(effects,
resourceAction);
+
+ expect(result).toEqual(
+ AccessPolicyActions.loadAccessPolicySuccess({
+ response: {
+ accessPolicy,
+ policyStatus: PolicyStatus.Inherited
+ }
+ })
+ );
+ });
+
+ it('should reset access policy with NotFound on 404', async () => {
+ const { effects, accessPolicyService } = await setup();
+ const resourceAction: ResourceAction = {
+ resource: 'connectors',
+ action: PolicyAction.Read
+ };
+
+ vi.spyOn(accessPolicyService, 'getAccessPolicy').mockReturnValue(
+ throwError(() => new HttpErrorResponse({ status: 404 })) as
never
+ );
+
+ const result = await dispatchLoadAccessPolicy(effects,
resourceAction);
+
+ expect(result).toEqual(
+ AccessPolicyActions.resetAccessPolicy({
+ response: {
+ policyStatus: PolicyStatus.NotFound
+ }
+ })
+ );
+ });
+
+ it('should reset access policy with Forbidden on 403', async () => {
+ const { effects, accessPolicyService } = await setup();
+ const resourceAction: ResourceAction = {
+ resource: 'connectors',
+ resourceIdentifier: 'data',
+ action: PolicyAction.Read
+ };
+
+ vi.spyOn(accessPolicyService, 'getAccessPolicy').mockReturnValue(
+ throwError(() => new HttpErrorResponse({ status: 403 })) as
never
+ );
+
+ const result = await dispatchLoadAccessPolicy(effects,
resourceAction);
+
+ expect(result).toEqual(
+ AccessPolicyActions.resetAccessPolicy({
+ response: {
+ policyStatus: PolicyStatus.Forbidden
+ }
+ })
+ );
+ });
+ });
+});
diff --git
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/access-policies/state/access-policy/access-policy.effects.ts
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/access-policies/state/access-policy/access-policy.effects.ts
index ee8458b52ce..bae145365e0 100644
---
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/access-policies/state/access-policy/access-policy.effects.ts
+++
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/access-policies/state/access-policy/access-policy.effects.ts
@@ -89,10 +89,7 @@ export class AccessPolicyEffects {
map((response) => {
const accessPolicy: AccessPolicyEntity = response;
- let requestedResource =
`/${request.resourceAction.resource}`;
- if (request.resourceAction.resourceIdentifier) {
- requestedResource +=
`/${request.resourceAction.resourceIdentifier}`;
- }
+ const requestedResource =
this.accessPoliciesService.buildResourcePath(request.resourceAction);
let policyStatus: PolicyStatus | undefined;
if (accessPolicy.component.resource ===
requestedResource) {
diff --git
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/access-policies/ui/component-access-policies/component-access-policies.component.html
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/access-policies/ui/component-access-policies/component-access-policies.component.html
index 88a5f2e1534..cefbb8cc43b 100644
---
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/access-policies/ui/component-access-policies/component-access-policies.component.html
+++
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/access-policies/ui/component-access-policies/component-access-policies.component.html
@@ -155,6 +155,27 @@
<a (click)="overridePolicy()">Override</a> this policy.
}
</ng-template>
+<ng-template #inheritedFromConnectors let-policy
let-supportsConfigurableAuthorizer="supportsConfigurableAuthorizer">
+ Showing effective policy inherited from global connectors policy.
+ @if (supportsConfigurableAuthorizer) {
+ <a (click)="overridePolicy()">Override</a> this policy.
+ }
+</ng-template>
+<ng-template #inheritedFromConnectorData let-policy
let-supportsConfigurableAuthorizer="supportsConfigurableAuthorizer">
+ Showing effective policy inherited from global connector data policy.
+ @if (supportsConfigurableAuthorizer) {
+ <a (click)="overridePolicy()">Override</a> this policy.
+ }
+</ng-template>
+<ng-template
+ #inheritedFromConnectorProvenance
+ let-policy
+ let-supportsConfigurableAuthorizer="supportsConfigurableAuthorizer">
+ Showing effective policy inherited from global connector provenance policy.
+ @if (supportsConfigurableAuthorizer) {
+ <a (click)="overridePolicy()">Override</a> this policy.
+ }
+</ng-template>
<ng-template #inheritedFromProcessGroup let-policy
let-supportsConfigurableAuthorizer="supportsConfigurableAuthorizer">
Showing effective policy inherited from <a
[routerLink]="getInheritedProcessGroupRoute(policy)">Process Group</a>.
@if (supportsConfigurableAuthorizer) {
diff --git
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/access-policies/ui/component-access-policies/component-access-policies.component.spec.ts
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/access-policies/ui/component-access-policies/component-access-policies.component.spec.ts
index 31fbf28426a..a0d985d666c 100644
---
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/access-policies/ui/component-access-policies/component-access-policies.component.spec.ts
+++
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/access-policies/ui/component-access-policies/component-access-policies.component.spec.ts
@@ -15,8 +15,10 @@
* limitations under the License.
*/
+import { TemplateRef } from '@angular/core';
import { ComponentAccessPolicies } from
'./component-access-policies.component';
import { ComponentFixture, TestBed } from '@angular/core/testing';
+import { ComponentType } from '@nifi/shared';
import { provideMockStore } from '@ngrx/store/testing';
import { initialState } from '../../state/access-policy/access-policy.reducer';
import { initialState as initialErrorState } from
'../../../../state/error/error.reducer';
@@ -52,4 +54,107 @@ describe('ComponentAccessPolicies', () => {
it('should create', () => {
expect(component).toBeTruthy();
});
+
+ describe('isComponentPolicy', () => {
+ const createPolicyComponentState = (resource: string,
allowRemoteAccess = false) => ({
+ resource,
+ allowRemoteAccess,
+ label: 'Test Component'
+ });
+
+ const createOption = (value: string) => ({
+ text: 'Test Option',
+ value,
+ description: 'Test description'
+ });
+
+ describe('connectors', () => {
+ it('should enable read-data policy for connectors', () => {
+ const state = createPolicyComponentState('connectors');
+ const option = createOption('read-data');
+ expect(component.isComponentPolicy(option, state as
any)).toBe(true);
+ });
+
+ it('should enable write-data policy for connectors', () => {
+ const state = createPolicyComponentState('connectors');
+ const option = createOption('write-data');
+ expect(component.isComponentPolicy(option, state as
any)).toBe(true);
+ });
+
+ it('should enable read-provenance-data policy for connectors', ()
=> {
+ const state = createPolicyComponentState('connectors');
+ const option = createOption('read-provenance-data');
+ expect(component.isComponentPolicy(option, state as
any)).toBe(true);
+ });
+
+ it('should disable write-send-data policy for connectors', () => {
+ const state = createPolicyComponentState('connectors');
+ const option = createOption('write-send-data');
+ expect(component.isComponentPolicy(option, state as
any)).toBe(false);
+ });
+
+ it('should disable write-receive-data policy for connectors', ()
=> {
+ const state = createPolicyComponentState('connectors');
+ const option = createOption('write-receive-data');
+ expect(component.isComponentPolicy(option, state as
any)).toBe(false);
+ });
+
+ it('should enable component policies for connectors', () => {
+ const state = createPolicyComponentState('connectors');
+
expect(component.isComponentPolicy(createOption('read-component'), state as
any)).toBe(true);
+
expect(component.isComponentPolicy(createOption('write-component'), state as
any)).toBe(true);
+
expect(component.isComponentPolicy(createOption('write-operation'), state as
any)).toBe(true);
+ });
+ });
+
+ describe('parameter-contexts', () => {
+ it('should disable data policies for parameter-contexts', () => {
+ const state = createPolicyComponentState('parameter-contexts');
+ expect(component.isComponentPolicy(createOption('read-data'),
state as any)).toBe(false);
+
expect(component.isComponentPolicy(createOption('write-operation'), state as
any)).toBe(false);
+ });
+ });
+ });
+
+ describe('getContextType', () => {
+ it('should return ComponentType.Connector for connectors resource', ()
=> {
+ component.resource = 'connectors';
+ expect(component.getContextType()).toBe(ComponentType.Connector);
+ });
+ });
+
+ describe('getTemplateForInheritedPolicy', () => {
+ const templateRef = {} as TemplateRef<unknown>;
+
+ beforeEach(() => {
+ component.inheritedFromConnectors = templateRef;
+ component.inheritedFromConnectorData = templateRef;
+ component.inheritedFromConnectorProvenance = templateRef;
+ component.inheritedFromProcessGroup = {} as TemplateRef<unknown>;
+ });
+
+ it('should use connector data template for /data/connectors', () => {
+ expect(
+ component.getTemplateForInheritedPolicy({
+ component: { resource: '/data/connectors' }
+ } as any)
+ ).toBe(component.inheritedFromConnectorData);
+ });
+
+ it('should use connector provenance template for
/provenance-data/connectors', () => {
+ expect(
+ component.getTemplateForInheritedPolicy({
+ component: { resource: '/provenance-data/connectors' }
+ } as any)
+ ).toBe(component.inheritedFromConnectorProvenance);
+ });
+
+ it('should use connectors template for /connectors', () => {
+ expect(
+ component.getTemplateForInheritedPolicy({
+ component: { resource: '/connectors' }
+ } as any)
+ ).toBe(component.inheritedFromConnectors);
+ });
+ });
});
diff --git
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/access-policies/ui/component-access-policies/component-access-policies.component.ts
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/access-policies/ui/component-access-policies/component-access-policies.component.ts
index 742ba8dccc9..be7120e82f3 100644
---
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/access-policies/ui/component-access-policies/component-access-policies.component.ts
+++
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/access-policies/ui/component-access-policies/component-access-policies.component.ts
@@ -49,7 +49,7 @@ import { PolicyComponentState } from
'../../state/policy-component';
import { ErrorContextKey } from '../../../../state/error';
@Component({
- selector: 'global-access-policies',
+ selector: 'component-access-policies',
templateUrl: './component-access-policies.component.html',
styleUrls: ['./component-access-policies.component.scss'],
standalone: false
@@ -136,6 +136,9 @@ export class ComponentAccessPolicies implements OnInit,
OnDestroy {
@ViewChild('inheritedFromPolicies') inheritedFromPolicies!:
TemplateRef<any>;
@ViewChild('inheritedFromController') inheritedFromController!:
TemplateRef<any>;
@ViewChild('inheritedFromGlobalParameterContexts')
inheritedFromGlobalParameterContexts!: TemplateRef<any>;
+ @ViewChild('inheritedFromConnectors') inheritedFromConnectors!:
TemplateRef<any>;
+ @ViewChild('inheritedFromConnectorData') inheritedFromConnectorData!:
TemplateRef<any>;
+ @ViewChild('inheritedFromConnectorProvenance')
inheritedFromConnectorProvenance!: TemplateRef<any>;
@ViewChild('inheritedFromProcessGroup') inheritedFromProcessGroup!:
TemplateRef<any>;
constructor() {
@@ -268,8 +271,7 @@ export class ComponentAccessPolicies implements OnInit,
OnDestroy {
}
} else if (
policyComponentState.resource === 'parameter-contexts' ||
- policyComponentState.resource === 'parameter-providers' ||
- policyComponentState.resource === 'connectors'
+ policyComponentState.resource === 'parameter-providers'
) {
switch (option.value) {
case 'read-data':
@@ -280,6 +282,13 @@ export class ComponentAccessPolicies implements OnInit,
OnDestroy {
case 'write-operation':
return false;
}
+ } else if (policyComponentState.resource === 'connectors') {
+ // Connectors support data policies for queue actions but not
site-to-site
+ switch (option.value) {
+ case 'write-send-data':
+ case 'write-receive-data':
+ return false;
+ }
} else if (policyComponentState.resource === 'labels') {
switch (option.value) {
case 'write-operation':
@@ -332,8 +341,9 @@ export class ComponentAccessPolicies implements OnInit,
OnDestroy {
case 'parameter-providers':
case 'parameter-contexts':
case 'reporting-tasks':
- case 'connectors':
return 'icon-drop';
+ case 'connectors':
+ return 'fa fa-plug';
}
return 'icon-group';
@@ -360,7 +370,7 @@ export class ComponentAccessPolicies implements OnInit,
OnDestroy {
case 'parameter-providers':
return ComponentType.ParameterProvider;
case 'connectors':
- return 'Connector';
+ return ComponentType.Connector;
}
return ComponentType.ProcessGroup;
@@ -430,6 +440,12 @@ export class ComponentAccessPolicies implements OnInit,
OnDestroy {
return this.inheritedFromController;
} else if (policy.component.resource === '/parameter-contexts') {
return this.inheritedFromGlobalParameterContexts;
+ } else if (policy.component.resource === '/data/connectors') {
+ return this.inheritedFromConnectorData;
+ } else if (policy.component.resource ===
'/provenance-data/connectors') {
+ return this.inheritedFromConnectorProvenance;
+ } else if (policy.component.resource === '/connectors') {
+ return this.inheritedFromConnectors;
}
return this.inheritedFromProcessGroup;
diff --git
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/access-policies/ui/global-access-policies/global-access-policies.component.html
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/access-policies/ui/global-access-policies/global-access-policies.component.html
index a9b1dd5ad5f..cb118b46809 100644
---
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/access-policies/ui/global-access-policies/global-access-policies.component.html
+++
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/access-policies/ui/global-access-policies/global-access-policies.component.html
@@ -72,8 +72,21 @@
<mat-form-field subscriptSizing="dynamic">
<mat-label>Action</mat-label>
<mat-select formControlName="action"
(selectionChange)="actionChanged()">
- <mat-option
[value]="Action.Read">view</mat-option>
- <mat-option
[value]="Action.Write">modify</mat-option>
+ @if (supportsConnectorActions) {
+ @for (option of
connectorActionOptions; track option) {
+ <mat-option
+ [value]="option.value"
+ nifiTooltip
+
[tooltipComponentType]="TextTip"
+
[tooltipInputData]="option.description"
+ [delayClose]="false"
+ >{{ option.text }}
+ </mat-option>
+ }
+ } @else {
+ <mat-option
[value]="Action.Read">view</mat-option>
+ <mat-option
[value]="Action.Write">modify</mat-option>
+ }
</mat-select>
</mat-form-field>
</div>
@@ -167,6 +180,27 @@
<a (click)="overridePolicy()">Override</a> this policy.
}
</ng-template>
+<ng-template #inheritedFromConnectors let-policy
let-supportsConfigurableAuthorizer="supportsConfigurableAuthorizer">
+ Showing effective policy inherited from global connectors policy.
+ @if (supportsConfigurableAuthorizer) {
+ <a (click)="overridePolicy()">Override</a> this policy.
+ }
+</ng-template>
+<ng-template #inheritedFromConnectorData let-policy
let-supportsConfigurableAuthorizer="supportsConfigurableAuthorizer">
+ Showing effective policy inherited from global connector data policy.
+ @if (supportsConfigurableAuthorizer) {
+ <a (click)="overridePolicy()">Override</a> this policy.
+ }
+</ng-template>
+<ng-template
+ #inheritedFromConnectorProvenance
+ let-policy
+ let-supportsConfigurableAuthorizer="supportsConfigurableAuthorizer">
+ Showing effective policy inherited from global connector provenance policy.
+ @if (supportsConfigurableAuthorizer) {
+ <a (click)="overridePolicy()">Override</a> this policy.
+ }
+</ng-template>
<ng-template
#inheritedFromNoRestrictions
let-policy
diff --git
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/access-policies/ui/global-access-policies/global-access-policies.component.spec.ts
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/access-policies/ui/global-access-policies/global-access-policies.component.spec.ts
index 9b370969aef..065cf118d0e 100644
---
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/access-policies/ui/global-access-policies/global-access-policies.component.spec.ts
+++
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/access-policies/ui/global-access-policies/global-access-policies.component.spec.ts
@@ -15,9 +15,10 @@
* limitations under the License.
*/
+import { TemplateRef } from '@angular/core';
import { GlobalAccessPolicies } from './global-access-policies.component';
import { ComponentFixture, TestBed } from '@angular/core/testing';
-import { provideMockStore } from '@ngrx/store/testing';
+import { MockStore, provideMockStore } from '@ngrx/store/testing';
import { initialState } from '../../state/access-policy/access-policy.reducer';
import { initialState as initialErrorState } from
'../../../../state/error/error.reducer';
import { errorFeatureKey } from '../../../../state/error';
@@ -35,10 +36,14 @@ import { flowConfigurationFeatureKey } from
'../../../../state/flow-configuratio
import * as fromFlowConfiguration from
'../../../../state/flow-configuration/flow-configuration.reducer';
import { NgxSkeletonLoaderComponent } from 'ngx-skeleton-loader';
import { selectCurrentRoute } from '@nifi/shared';
+import { Action } from '../../state/shared';
+import { selectGlobalAccessPolicy } from
'../../state/access-policy/access-policy.actions';
+import type { MockInstance } from 'vitest';
describe('GlobalAccessPolicies', () => {
let component: GlobalAccessPolicies;
let fixture: ComponentFixture<GlobalAccessPolicies>;
+ let store: MockStore;
beforeEach(() => {
TestBed.configureTestingModule({
@@ -72,10 +77,267 @@ describe('GlobalAccessPolicies', () => {
});
fixture = TestBed.createComponent(GlobalAccessPolicies);
component = fixture.componentInstance;
+ store = TestBed.inject(MockStore);
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
+
+ describe('connectorActionOptions', () => {
+ it('should have extended action options for connectors', () => {
+ expect(component.connectorActionOptions).toBeDefined();
+ expect(component.connectorActionOptions.length).toBe(5);
+
+ const values = component.connectorActionOptions.map((o) =>
o.value);
+ expect(values).toContain('read');
+ expect(values).toContain('write');
+ expect(values).toContain('read-data');
+ expect(values).toContain('write-data');
+ expect(values).toContain('read-provenance-data');
+ });
+ });
+
+ describe('resourceChanged', () => {
+ it('should set supportsConnectorActions to true when connectors is
selected', () => {
+ component.resourceChanged('connectors');
+ expect(component.supportsConnectorActions).toBe(true);
+ expect(component.supportsReadWriteAction).toBe(true);
+ });
+
+ it('should set supportsConnectorActions to false for non-connector
resources', () => {
+ component.resourceChanged('controller');
+ expect(component.supportsConnectorActions).toBe(false);
+ expect(component.supportsReadWriteAction).toBe(true);
+ });
+
+ it('should reset action to read when connectors is selected', () => {
+ component.resourceChanged('connectors');
+ expect(component.policyForm.get('action')?.value).toBe('read');
+ });
+ });
+
+ describe('actionChanged with connector extended actions', () => {
+ let dispatchSpy: MockInstance;
+
+ beforeEach(() => {
+ dispatchSpy = vi.spyOn(store, 'dispatch');
+ });
+
+ it('should map read-data action to connectors with data
resourceIdentifier', () => {
+ component.policyForm.get('resource')?.setValue('connectors');
+ component.policyForm.get('action')?.setValue('read-data');
+ component.actionChanged();
+
+ expect(dispatchSpy).toHaveBeenCalledWith(
+ selectGlobalAccessPolicy({
+ request: {
+ resourceAction: {
+ resource: 'connectors',
+ action: Action.Read,
+ resourceIdentifier: 'data'
+ }
+ }
+ })
+ );
+ });
+
+ it('should map write-data action to connectors with data
resourceIdentifier', () => {
+ component.policyForm.get('resource')?.setValue('connectors');
+ component.policyForm.get('action')?.setValue('write-data');
+ component.actionChanged();
+
+ expect(dispatchSpy).toHaveBeenCalledWith(
+ selectGlobalAccessPolicy({
+ request: {
+ resourceAction: {
+ resource: 'connectors',
+ action: Action.Write,
+ resourceIdentifier: 'data'
+ }
+ }
+ })
+ );
+ });
+
+ it('should map read-provenance-data action to connectors with
provenance-data resourceIdentifier', () => {
+ component.policyForm.get('resource')?.setValue('connectors');
+
component.policyForm.get('action')?.setValue('read-provenance-data');
+ component.actionChanged();
+
+ expect(dispatchSpy).toHaveBeenCalledWith(
+ selectGlobalAccessPolicy({
+ request: {
+ resourceAction: {
+ resource: 'connectors',
+ action: Action.Read,
+ resourceIdentifier: 'provenance-data'
+ }
+ }
+ })
+ );
+ });
+
+ it('should map read action to connectors resource with read action',
() => {
+ component.policyForm.get('resource')?.setValue('connectors');
+ component.policyForm.get('action')?.setValue('read');
+ component.actionChanged();
+
+ expect(dispatchSpy).toHaveBeenCalledWith(
+ selectGlobalAccessPolicy({
+ request: {
+ resourceAction: {
+ resource: 'connectors',
+ action: Action.Read,
+ resourceIdentifier: undefined
+ }
+ }
+ })
+ );
+ });
+
+ it('should map write action to connectors resource with write action',
() => {
+ component.policyForm.get('resource')?.setValue('connectors');
+ component.policyForm.get('action')?.setValue('write');
+ component.actionChanged();
+
+ expect(dispatchSpy).toHaveBeenCalledWith(
+ selectGlobalAccessPolicy({
+ request: {
+ resourceAction: {
+ resource: 'connectors',
+ action: Action.Write,
+ resourceIdentifier: undefined
+ }
+ }
+ })
+ );
+ });
+ });
+
+ describe('mapRouteResourceToFormState', () => {
+ const callMapRouteResourceToFormState = (
+ comp: GlobalAccessPolicies,
+ resourceAction: { resource: string; action: string;
resourceIdentifier?: string }
+ ) => {
+ return (comp as any).mapRouteResourceToFormState(resourceAction);
+ };
+
+ it('should map connectors with data resourceIdentifier and read action
to read-data', () => {
+ const result = callMapRouteResourceToFormState(component, {
+ resource: 'connectors',
+ action: Action.Read,
+ resourceIdentifier: 'data'
+ });
+
+ expect(result).toEqual({
+ resource: 'connectors',
+ action: 'read-data',
+ supportsConnectorActions: true
+ });
+ });
+
+ it('should map connectors with data resourceIdentifier and write
action to write-data', () => {
+ const result = callMapRouteResourceToFormState(component, {
+ resource: 'connectors',
+ action: Action.Write,
+ resourceIdentifier: 'data'
+ });
+
+ expect(result).toEqual({
+ resource: 'connectors',
+ action: 'write-data',
+ supportsConnectorActions: true
+ });
+ });
+
+ it('should map connectors with provenance-data resourceIdentifier to
read-provenance-data', () => {
+ const result = callMapRouteResourceToFormState(component, {
+ resource: 'connectors',
+ action: Action.Read,
+ resourceIdentifier: 'provenance-data'
+ });
+
+ expect(result).toEqual({
+ resource: 'connectors',
+ action: 'read-provenance-data',
+ supportsConnectorActions: true
+ });
+ });
+
+ it('should map connectors with read action to connectors with read',
() => {
+ const result = callMapRouteResourceToFormState(component, {
+ resource: 'connectors',
+ action: Action.Read
+ });
+
+ expect(result).toEqual({
+ resource: 'connectors',
+ action: 'read',
+ supportsConnectorActions: true
+ });
+ });
+
+ it('should map connectors with write action to connectors with write',
() => {
+ const result = callMapRouteResourceToFormState(component, {
+ resource: 'connectors',
+ action: Action.Write
+ });
+
+ expect(result).toEqual({
+ resource: 'connectors',
+ action: 'write',
+ supportsConnectorActions: true
+ });
+ });
+
+ it('should pass through non-connector resources unchanged', () => {
+ const result = callMapRouteResourceToFormState(component, {
+ resource: 'controller',
+ action: Action.Read
+ });
+
+ expect(result).toEqual({
+ resource: 'controller',
+ action: Action.Read,
+ supportsConnectorActions: false
+ });
+ });
+ });
+
+ describe('getTemplateForInheritedPolicy', () => {
+ const templateRef = {} as TemplateRef<unknown>;
+
+ beforeEach(() => {
+ component.inheritedFromConnectors = templateRef;
+ component.inheritedFromConnectorData = templateRef;
+ component.inheritedFromConnectorProvenance = templateRef;
+ component.inheritedFromNoRestrictions = {} as TemplateRef<unknown>;
+ });
+
+ it('should use connector data template for /data/connectors', () => {
+ expect(
+ component.getTemplateForInheritedPolicy({
+ component: { resource: '/data/connectors' }
+ } as any)
+ ).toBe(component.inheritedFromConnectorData);
+ });
+
+ it('should use connector provenance template for
/provenance-data/connectors', () => {
+ expect(
+ component.getTemplateForInheritedPolicy({
+ component: { resource: '/provenance-data/connectors' }
+ } as any)
+ ).toBe(component.inheritedFromConnectorProvenance);
+ });
+
+ it('should use connectors template for /connectors', () => {
+ expect(
+ component.getTemplateForInheritedPolicy({
+ component: { resource: '/connectors' }
+ } as any)
+ ).toBe(component.inheritedFromConnectors);
+ });
+ });
});
diff --git
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/access-policies/ui/global-access-policies/global-access-policies.component.ts
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/access-policies/ui/global-access-policies/global-access-policies.component.ts
index d84692cf9d4..cb34616fce3 100644
---
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/access-policies/ui/global-access-policies/global-access-policies.component.ts
+++
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/access-policies/ui/global-access-policies/global-access-policies.component.ts
@@ -74,9 +74,42 @@ export class GlobalAccessPolicies implements OnInit,
OnDestroy {
requiredPermissionOptions!: SelectOption[];
supportsReadWriteAction = false;
supportsResourceIdentifier = false;
+ supportsConnectorActions = false;
+
+ // Extended action options for connectors policy
+ connectorActionOptions: SelectOption[] = [
+ {
+ text: 'view',
+ value: 'read',
+ description: 'Allows users to view Connectors'
+ },
+ {
+ text: 'modify',
+ value: 'write',
+ description: 'Allows users to modify Connectors'
+ },
+ {
+ text: 'view the data',
+ value: 'read-data',
+ description: 'Allows users to view metadata and content in
flowfile queues in connector process groups'
+ },
+ {
+ text: 'modify the data',
+ value: 'write-data',
+ description: 'Allows users to empty flowfile queues in connector
process groups'
+ },
+ {
+ text: 'view provenance',
+ value: 'read-provenance-data',
+ description: 'Allows users to view provenance events for connector
components'
+ }
+ ];
@ViewChild('inheritedFromPolicies') inheritedFromPolicies!:
TemplateRef<any>;
@ViewChild('inheritedFromController') inheritedFromController!:
TemplateRef<any>;
+ @ViewChild('inheritedFromConnectors') inheritedFromConnectors!:
TemplateRef<any>;
+ @ViewChild('inheritedFromConnectorData') inheritedFromConnectorData!:
TemplateRef<any>;
+ @ViewChild('inheritedFromConnectorProvenance')
inheritedFromConnectorProvenance!: TemplateRef<any>;
@ViewChild('inheritedFromNoRestrictions') inheritedFromNoRestrictions!:
TemplateRef<any>;
constructor() {
@@ -137,12 +170,17 @@ export class GlobalAccessPolicies implements OnInit,
OnDestroy {
)
.subscribe((resourceAction) => {
if (resourceAction) {
- this.supportsReadWriteAction =
this.globalPolicySupportsReadWrite(resourceAction.resource);
+ const mappedState =
this.mapRouteResourceToFormState(resourceAction);
+
+ this.supportsConnectorActions =
mappedState.supportsConnectorActions;
+ this.supportsReadWriteAction =
+ mappedState.supportsConnectorActions ||
+
this.globalPolicySupportsReadWrite(mappedState.resource);
-
this.policyForm.get('resource')?.setValue(resourceAction.resource);
-
this.policyForm.get('action')?.setValue(resourceAction.action);
+
this.policyForm.get('resource')?.setValue(mappedState.resource);
+
this.policyForm.get('action')?.setValue(mappedState.action);
-
this.updateResourceIdentifierVisibility(resourceAction.resource);
+
this.updateResourceIdentifierVisibility(mappedState.resource);
if (resourceAction.resource === 'restricted-components' &&
resourceAction.resourceIdentifier) {
this.policyForm.get('resourceIdentifier')?.setValue(resourceAction.resourceIdentifier);
@@ -159,6 +197,45 @@ export class GlobalAccessPolicies implements OnInit,
OnDestroy {
});
}
+ /**
+ * Maps a route resource/action to form state, handling connector extended
actions.
+ * When the route is 'connectors' with a resourceIdentifier of 'data' or
'provenance-data',
+ * this maps to the appropriate extended action in the form.
+ */
+ private mapRouteResourceToFormState(resourceAction: {
+ resource: string;
+ action: string;
+ resourceIdentifier?: string;
+ }): { resource: string; action: string; supportsConnectorActions: boolean
} {
+ if (resourceAction.resource === 'connectors') {
+ if (resourceAction.resourceIdentifier === 'data') {
+ return {
+ resource: 'connectors',
+ action: resourceAction.action === Action.Read ?
'read-data' : 'write-data',
+ supportsConnectorActions: true
+ };
+ } else if (resourceAction.resourceIdentifier ===
'provenance-data') {
+ return {
+ resource: 'connectors',
+ action: 'read-provenance-data',
+ supportsConnectorActions: true
+ };
+ } else {
+ return {
+ resource: 'connectors',
+ action: resourceAction.action === Action.Read ? 'read' :
'write',
+ supportsConnectorActions: true
+ };
+ }
+ }
+
+ return {
+ resource: resourceAction.resource,
+ action: resourceAction.action,
+ supportsConnectorActions: false
+ };
+ }
+
ngOnInit(): void {
this.store.dispatch(loadTenants());
this.store.dispatch(loadExtensionTypesForPolicies());
@@ -169,7 +246,13 @@ export class GlobalAccessPolicies implements OnInit,
OnDestroy {
}
resourceChanged(value: string): void {
- if (this.globalPolicySupportsReadWrite(value)) {
+ this.supportsConnectorActions = value === 'connectors';
+
+ if (this.supportsConnectorActions) {
+ this.supportsReadWriteAction = true;
+ this.supportsResourceIdentifier = false;
+ this.policyForm.get('action')?.setValue('read');
+ } else if (this.globalPolicySupportsReadWrite(value)) {
this.supportsReadWriteAction = true;
this.supportsResourceIdentifier = false;
@@ -184,24 +267,13 @@ export class GlobalAccessPolicies implements OnInit,
OnDestroy {
this.updateResourceIdentifierVisibility(value);
}
- this.store.dispatch(
- selectGlobalAccessPolicy({
- request: {
- resourceAction: {
- resource: this.policyForm.get('resource')?.value,
- action: this.policyForm.get('action')?.value,
- resourceIdentifier:
this.policyForm.get('resourceIdentifier')?.value
- }
- }
- })
- );
+ this.dispatchPolicySelection();
}
private globalPolicySupportsReadWrite(resource: string): boolean {
return (
resource === 'controller' ||
resource === 'parameter-contexts' ||
- resource === 'connectors' ||
resource === 'counters' ||
resource === 'policies' ||
resource === 'tenants'
@@ -223,13 +295,54 @@ export class GlobalAccessPolicies implements OnInit,
OnDestroy {
}
actionChanged(): void {
+ this.dispatchPolicySelection();
+ }
+
+ /**
+ * Dispatches the policy selection action with proper resource/action
mapping.
+ * For connectors, extended actions like 'read-data' are mapped to use
+ * resourceIdentifier (e.g., 'connectors' with resourceIdentifier 'data').
+ */
+ private dispatchPolicySelection(): void {
+ const selectedResource = this.policyForm.get('resource')?.value;
+ const selectedAction = this.policyForm.get('action')?.value;
+
+ const resource = selectedResource;
+ let action = selectedAction;
+ let resourceIdentifier =
this.policyForm.get('resourceIdentifier')?.value;
+
+ if (selectedResource === 'connectors') {
+ switch (selectedAction) {
+ case 'read':
+ action = Action.Read;
+ resourceIdentifier = undefined;
+ break;
+ case 'write':
+ action = Action.Write;
+ resourceIdentifier = undefined;
+ break;
+ case 'read-data':
+ action = Action.Read;
+ resourceIdentifier = 'data';
+ break;
+ case 'write-data':
+ action = Action.Write;
+ resourceIdentifier = 'data';
+ break;
+ case 'read-provenance-data':
+ action = Action.Read;
+ resourceIdentifier = 'provenance-data';
+ break;
+ }
+ }
+
this.store.dispatch(
selectGlobalAccessPolicy({
request: {
resourceAction: {
- resource: this.policyForm.get('resource')?.value,
- action: this.policyForm.get('action')?.value,
- resourceIdentifier:
this.policyForm.get('resourceIdentifier')?.value
+ resource,
+ action,
+ resourceIdentifier
}
}
})
@@ -255,6 +368,12 @@ export class GlobalAccessPolicies implements OnInit,
OnDestroy {
return this.inheritedFromPolicies;
} else if (policy.component.resource === '/controller') {
return this.inheritedFromController;
+ } else if (policy.component.resource === '/data/connectors') {
+ return this.inheritedFromConnectorData;
+ } else if (policy.component.resource ===
'/provenance-data/connectors') {
+ return this.inheritedFromConnectorProvenance;
+ } else if (policy.component.resource === '/connectors') {
+ return this.inheritedFromConnectors;
}
return this.inheritedFromNoRestrictions;
diff --git
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/queue/state/queue-listing/queue-listing.effects.spec.ts
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/queue/state/queue-listing/queue-listing.effects.spec.ts
new file mode 100644
index 00000000000..2bf633c7f01
--- /dev/null
+++
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/queue/state/queue-listing/queue-listing.effects.spec.ts
@@ -0,0 +1,202 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { TestBed } from '@angular/core/testing';
+import { provideMockActions } from '@ngrx/effects/testing';
+import { MockStore, provideMockStore } from '@ngrx/store/testing';
+import { Action } from '@ngrx/store';
+import { MatDialog } from '@angular/material/dialog';
+import { HttpErrorResponse } from '@angular/common/http';
+import { firstValueFrom, Observable, of, Subject, throwError } from 'rxjs';
+import { QueueListingEffects } from './queue-listing.effects';
+import {
+ pollQueueListingRequest,
+ queueListingApiError,
+ stopPollingQueueListingRequest,
+ submitQueueListingRequest
+} from './queue-listing.actions';
+import { selectActiveListingRequest, selectSelectedConnection } from
'./queue-listing.selectors';
+import { QueueService } from '../../service/queue.service';
+import { ErrorHelper } from '../../../../service/error-helper.service';
+import { NiFiCommon } from '@nifi/shared';
+import * as ErrorActions from '../../../../state/error/error.actions';
+import { ErrorContextKey } from '../../../../state/error';
+import { ListingRequest } from './index';
+
+function createListingRequest(overrides: Partial<ListingRequest> = {}):
ListingRequest {
+ return {
+ id: 'listing-1',
+ uri: 'http://example/listing-requests/listing-1',
+ submissionTime: '2026-01-01T00:00:00Z',
+ lastUpdated: '2026-01-01T00:00:01Z',
+ percentCompleted: 0,
+ finished: false,
+ failureReason: '',
+ maxResults: 100,
+ sourceRunning: true,
+ destinationRunning: true,
+ state: 'RUNNING',
+ queueSize: { byteCount: 0, objectCount: 0 },
+ flowFileSummaries: [],
+ ...overrides
+ };
+}
+
+describe('QueueListingEffects', () => {
+ async function setup(
+ options: {
+ selectedConnection?: { id: string; label: string } | null;
+ activeListingRequest?: ListingRequest | null;
+ queueService?: Partial<QueueService>;
+ } = {}
+ ) {
+ let actions$: Observable<Action>;
+
+ const exit$ = new Subject<void>();
+ const mockDialog = {
+ open: vi.fn().mockReturnValue({
+ componentInstance: {
+ exit: exit$.asObservable()
+ }
+ }),
+ closeAll: vi.fn()
+ };
+
+ await TestBed.configureTestingModule({
+ providers: [
+ QueueListingEffects,
+ provideMockActions(() => actions$),
+ provideMockStore({
+ initialState: {},
+ selectors: [
+ {
+ selector: selectSelectedConnection,
+ value:
+ options.selectedConnection !== undefined
+ ? options.selectedConnection
+ : { id: 'conn-1', label: 'Connection' }
+ },
+ {
+ selector: selectActiveListingRequest,
+ value:
+ options.activeListingRequest !== undefined
+ ? options.activeListingRequest
+ : createListingRequest()
+ }
+ ]
+ }),
+ { provide: MatDialog, useValue: mockDialog },
+ {
+ provide: QueueService,
+ useValue: {
+ submitQueueListingRequest: vi.fn(),
+ pollQueueListingRequest: vi.fn(),
+ ...options.queueService
+ }
+ },
+ {
+ provide: ErrorHelper,
+ useValue: {
+ getErrorString: vi.fn().mockReturnValue('queue listing
failed')
+ }
+ },
+ {
+ provide: NiFiCommon,
+ useValue: {
+ isBlank: (value: string) => !value?.trim()
+ }
+ }
+ ]
+ }).compileComponents();
+
+ const effects = TestBed.inject(QueueListingEffects);
+ const store = TestBed.inject(MockStore);
+
+ return {
+ effects,
+ store,
+ mockDialog,
+ actions$: (stream: Observable<Action>) => {
+ actions$ = stream;
+ }
+ };
+ }
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ describe('queueListingApiError$', () => {
+ it('should close dialogs, stop polling, and surface a queue banner
error', async () => {
+ const { effects, store, mockDialog, actions$ } = await setup();
+ actions$(of(queueListingApiError({ error: 'listing failed' })));
+ const dispatchSpy = vi.spyOn(store, 'dispatch');
+
+ const action = await firstValueFrom(effects.queueListingApiError$);
+
+ expect(mockDialog.closeAll).toHaveBeenCalled();
+
expect(dispatchSpy).toHaveBeenCalledWith(stopPollingQueueListingRequest());
+ expect(action).toEqual(
+ ErrorActions.addBannerError({
+ errorContext: { errors: ['listing failed'], context:
ErrorContextKey.QUEUE }
+ })
+ );
+ });
+ });
+
+ describe('submitQueueListingRequest$', () => {
+ it('should dispatch queueListingApiError when submit fails', async ()
=> {
+ const errorResponse = new HttpErrorResponse({ status: 500,
statusText: 'Server Error' });
+ const { effects, actions$ } = await setup({
+ queueService: {
+ submitQueueListingRequest:
vi.fn().mockReturnValue(throwError(() => errorResponse))
+ }
+ });
+ actions$(of(submitQueueListingRequest({ request: { connectionId:
'conn-1' } })));
+
+ const action = await
firstValueFrom(effects.submitQueueListingRequest$);
+
+ expect(action).toEqual(
+ queueListingApiError({
+ error: 'queue listing failed'
+ })
+ );
+ });
+ });
+
+ describe('pollQueueListingRequest$', () => {
+ it('should dispatch queueListingApiError when poll fails', async () =>
{
+ const errorResponse = new HttpErrorResponse({ status: 503,
statusText: 'Service Unavailable' });
+ const pollQueueListingRequestSpy =
vi.fn().mockReturnValue(throwError(() => errorResponse));
+ const { effects, actions$ } = await setup({
+ queueService: {
+ pollQueueListingRequest: pollQueueListingRequestSpy
+ }
+ });
+ actions$(of(pollQueueListingRequest()));
+
+ const action = await
firstValueFrom(effects.pollQueueListingRequest$);
+
+ expect(pollQueueListingRequestSpy).toHaveBeenCalledWith('conn-1',
'listing-1');
+ expect(action).toEqual(
+ queueListingApiError({
+ error: 'queue listing failed'
+ })
+ );
+ });
+ });
+});
diff --git
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/queue/state/queue-listing/queue-listing.effects.ts
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/queue/state/queue-listing/queue-listing.effects.ts
index e82ccfd880c..413f87c91c5 100644
---
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/queue/state/queue-listing/queue-listing.effects.ts
+++
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/queue/state/queue-listing/queue-listing.effects.ts
@@ -37,7 +37,6 @@ import { isDefinedAndNotNull, NiFiCommon, LARGE_DIALOG } from
'@nifi/shared';
import { HttpErrorResponse } from '@angular/common/http';
import * as ErrorActions from '../../../../state/error/error.actions';
import { ErrorHelper } from '../../../../service/error-helper.service';
-import { stopPollingQueueListingRequest } from './queue-listing.actions';
import { ErrorContextKey } from '../../../../state/error';
@Injectable()
@@ -112,19 +111,13 @@ export class QueueListingEffects {
}
})
),
- catchError((errorResponse: HttpErrorResponse) => {
- if
(this.errorHelper.showErrorInContext(errorResponse.status)) {
- return of(
- QueueListingActions.queueListingApiError({
- error:
this.errorHelper.getErrorString(errorResponse)
- })
- );
- } else {
-
this.store.dispatch(stopPollingQueueListingRequest());
-
- return
of(this.errorHelper.fullScreenError(errorResponse));
- }
- })
+ catchError((errorResponse: HttpErrorResponse) =>
+ of(
+ QueueListingActions.queueListingApiError({
+ error:
this.errorHelper.getErrorString(errorResponse)
+ })
+ )
+ )
);
})
)
@@ -183,19 +176,13 @@ export class QueueListingEffects {
}
})
),
- catchError((errorResponse: HttpErrorResponse) => {
- if
(this.errorHelper.showErrorInContext(errorResponse.status)) {
- return of(
- QueueListingActions.queueListingApiError({
- error:
this.errorHelper.getErrorString(errorResponse)
- })
- );
- } else {
-
this.store.dispatch(stopPollingQueueListingRequest());
-
- return
of(this.errorHelper.fullScreenError(errorResponse));
- }
- })
+ catchError((errorResponse: HttpErrorResponse) =>
+ of(
+ QueueListingActions.queueListingApiError({
+ error:
this.errorHelper.getErrorString(errorResponse)
+ })
+ )
+ )
);
})
)
@@ -349,6 +336,7 @@ export class QueueListingEffects {
this.actions$.pipe(
ofType(QueueListingActions.queueListingApiError),
tap(() => {
+ this.dialog.closeAll();
this.store.dispatch(QueueListingActions.stopPollingQueueListingRequest());
}),
switchMap(({ error }) =>
diff --git
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/users/ui/user-listing/user-access-policies/user-access-policies.component.spec.ts
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/users/ui/user-listing/user-access-policies/user-access-policies.component.spec.ts
index 5fcba7ad631..fe839bdf3c2 100644
---
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/users/ui/user-listing/user-access-policies/user-access-policies.component.spec.ts
+++
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/users/ui/user-listing/user-access-policies/user-access-policies.component.spec.ts
@@ -66,4 +66,127 @@ describe('UserAccessPolicies', () => {
it('should create', () => {
expect(component).toBeTruthy();
});
+
+ describe('formatPolicy', () => {
+ it('should label per-instance connector policies', () => {
+ const label = component.formatPolicy({
+ permissions: { canRead: true, canWrite: true },
+ component: {
+ resource: '/connectors/connector-1',
+ action: 'read',
+ componentReference: {
+ id: 'connector-1',
+ permissions: { canRead: true, canWrite: true },
+ component: { name: 'My Connector' }
+ }
+ }
+ } as any);
+
+ expect(label).toBe('Component policy for connector My Connector');
+ });
+
+ it('should label connector data policies', () => {
+ const label = component.formatPolicy({
+ permissions: { canRead: true, canWrite: true },
+ component: {
+ resource: '/data/connectors/connector-1',
+ action: 'read',
+ componentReference: {
+ id: 'connector-1',
+ permissions: { canRead: true, canWrite: true },
+ component: { name: 'My Connector' }
+ }
+ }
+ } as any);
+
+ expect(label).toBe('Data policy for connector My Connector');
+ });
+
+ it('should label connector provenance policies', () => {
+ const label = component.formatPolicy({
+ permissions: { canRead: true, canWrite: true },
+ component: {
+ resource: '/provenance-data/connectors/connector-1',
+ action: 'read',
+ componentReference: {
+ id: 'connector-1',
+ permissions: { canRead: true, canWrite: true },
+ component: { name: 'My Connector' }
+ }
+ }
+ } as any);
+
+ expect(label).toBe('Provenance policy for connector My Connector');
+ });
+
+ it('should label global connector data read policy', () => {
+ const label = component.formatPolicy({
+ permissions: { canRead: true, canWrite: true },
+ component: {
+ resource: '/data/connectors',
+ action: 'read',
+ configurable: true
+ }
+ } as any);
+
+ expect(label).toBe('Global policy to view the data for
connectors');
+ });
+
+ it('should label global connector data write policy', () => {
+ const label = component.formatPolicy({
+ permissions: { canRead: true, canWrite: true },
+ component: {
+ resource: '/data/connectors',
+ action: 'write',
+ configurable: true
+ }
+ } as any);
+
+ expect(label).toBe('Global policy to modify the data for
connectors');
+ });
+
+ it('should label global connector provenance policy', () => {
+ const label = component.formatPolicy({
+ permissions: { canRead: true, canWrite: true },
+ component: {
+ resource: '/provenance-data/connectors',
+ action: 'read',
+ configurable: true
+ }
+ } as any);
+
+ expect(label).toBe('Global policy to view provenance for
connectors');
+ });
+
+ it('should label global connectors policy via policy type listing', ()
=> {
+ const label = component.formatPolicy({
+ permissions: { canRead: true, canWrite: true },
+ component: {
+ resource: '/connectors',
+ action: 'read',
+ configurable: true
+ }
+ } as any);
+
+ expect(label).toBe('Global policy to access connectors');
+ });
+ });
+
+ describe('getPolicyTargetLink', () => {
+ it('should link to the connectors listing for connector policies', ()
=> {
+ const link = component.getPolicyTargetLink({
+ permissions: { canRead: true, canWrite: true },
+ component: {
+ resource: '/connectors/connector-1',
+ action: 'read',
+ componentReference: {
+ id: 'connector-1',
+ permissions: { canRead: true, canWrite: true }
+ }
+ }
+ } as any);
+
+ expect(link).toEqual(['/connectors', 'connector-1']);
+ });
+ });
});
diff --git
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/users/ui/user-listing/user-access-policies/user-access-policies.component.ts
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/users/ui/user-listing/user-access-policies/user-access-policies.component.ts
index 4d9814989ab..45dfe7a7c63 100644
---
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/users/ui/user-listing/user-access-policies/user-access-policies.component.ts
+++
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/users/ui/user-listing/user-access-policies/user-access-policies.component.ts
@@ -106,6 +106,11 @@ export class UserAccessPolicies extends
CloseOnEscapeDialog {
// not restricted/global policy... check if user has access to the
component reference
return this.componentResourceParser(policy);
} else {
+ const globalLabel =
this.parseGlobalPolicyResource(policy.component.resource,
policy.component.action);
+ if (globalLabel) {
+ return globalLabel;
+ }
+
// may be a global policy
const policyValue: string =
this.nifiCommon.substringAfterLast(policy.component.resource, '/');
const policyOption: SelectOption | undefined =
this.nifiCommon.getPolicyTypeListing(policyValue);
@@ -119,6 +124,21 @@ export class UserAccessPolicies extends
CloseOnEscapeDialog {
}
}
+ /**
+ * Labels global connector data and provenance policies whose resource
paths are not
+ * represented in the policy type listing (e.g. /data/connectors vs
/connectors).
+ */
+ private parseGlobalPolicyResource(resource: string, action: string):
string | null {
+ if (resource === '/data/connectors') {
+ const text = action === 'write' ? 'modify the data' : 'view the
data';
+ return `Global policy to ${text} for connectors`;
+ }
+ if (resource === '/provenance-data/connectors') {
+ return 'Global policy to view provenance for connectors';
+ }
+ return null;
+ }
+
/**
* Generates a human-readable restricted component policy string.
*
@@ -156,6 +176,9 @@ export class UserAccessPolicies extends CloseOnEscapeDialog
{
} else if (resource.startsWith('/data')) {
resource = this.nifiCommon.substringAfterFirst(resource, '/data');
policyLabel += 'Data policy for ';
+ } else if (resource.startsWith('/provenance-data')) {
+ resource = this.nifiCommon.substringAfterFirst(resource,
'/provenance-data');
+ policyLabel += 'Provenance policy for ';
} else if (resource.startsWith('/operation')) {
resource = this.nifiCommon.substringAfterFirst(resource,
'/operation');
policyLabel += 'Operate policy for ';
@@ -183,6 +206,8 @@ export class UserAccessPolicies extends CloseOnEscapeDialog
{
policyLabel += 'reporting task ';
} else if (resource.startsWith('/parameter-contexts')) {
policyLabel += 'parameter context ';
+ } else if (resource.startsWith('/connectors')) {
+ policyLabel += 'connector ';
}
const componentReference: ComponentReferenceEntity | undefined =
policy.component.componentReference;
@@ -291,6 +316,8 @@ export class UserAccessPolicies extends CloseOnEscapeDialog
{
return ['/settings', 'reporting-tasks', componentReference.id];
} else if (resource.indexOf('/parameter-contexts') >= 0) {
return ['/parameter-contexts', componentReference.id];
+ } else if (resource.indexOf('/connectors') >= 0) {
+ return ['/connectors', componentReference.id];
}
return ['/'];
}
diff --git
a/nifi-frontend/src/main/frontend/libs/shared/src/components/component-context/component-context.component.html
b/nifi-frontend/src/main/frontend/libs/shared/src/components/component-context/component-context.component.html
index f7fdf08f8b2..c5498610dcc 100644
---
a/nifi-frontend/src/main/frontend/libs/shared/src/components/component-context/component-context.component.html
+++
b/nifi-frontend/src/main/frontend/libs/shared/src/components/component-context/component-context.component.html
@@ -17,8 +17,12 @@
<div class="component-context flex flex-col">
<div class="flex gap-x-1 items-center">
- <div class="context-logo text-3xl flex flex-col">
- <i class="icon tertiary-color" [class]="componentIconClass"></i>
+ <div class="context-logo text-3xl flex items-center">
+ @if (componentIcon.iconType === 'font-awesome') {
+ <i class="fa mr-2 tertiary-color"
[class]="componentIcon.className"></i>
+ } @else {
+ <i class="icon tertiary-color"
[class]="componentIcon.className"></i>
+ }
</div>
<div class="flex flex-col flex-1">
<div class="context-name w-full">{{ name }}</div>
diff --git
a/nifi-frontend/src/main/frontend/libs/shared/src/components/component-context/component-context.component.spec.ts
b/nifi-frontend/src/main/frontend/libs/shared/src/components/component-context/component-context.component.spec.ts
index 51ae4b3490f..05cef1325e3 100644
---
a/nifi-frontend/src/main/frontend/libs/shared/src/components/component-context/component-context.component.spec.ts
+++
b/nifi-frontend/src/main/frontend/libs/shared/src/components/component-context/component-context.component.spec.ts
@@ -16,7 +16,7 @@
*/
import { ComponentFixture, TestBed } from '@angular/core/testing';
-
+import { ComponentType } from '../../types';
import { ComponentContext } from './component-context.component';
describe('ComponentContext', () => {
@@ -36,4 +36,20 @@ describe('ComponentContext', () => {
it('should create', () => {
expect(component).toBeTruthy();
});
+
+ it('should use font awesome plug icon for connectors', () => {
+ component.type = ComponentType.Connector;
+ expect(component.componentIcon).toEqual({
+ className: 'fa-plug',
+ iconType: 'font-awesome'
+ });
+ });
+
+ it('should use flowfont for processors', () => {
+ component.type = ComponentType.Processor;
+ expect(component.componentIcon).toEqual({
+ className: 'icon-processor',
+ iconType: 'flowfont'
+ });
+ });
});
diff --git
a/nifi-frontend/src/main/frontend/libs/shared/src/components/component-context/component-context.component.ts
b/nifi-frontend/src/main/frontend/libs/shared/src/components/component-context/component-context.component.ts
index 535f049f557..b34c92b7bc8 100644
---
a/nifi-frontend/src/main/frontend/libs/shared/src/components/component-context/component-context.component.ts
+++
b/nifi-frontend/src/main/frontend/libs/shared/src/components/component-context/component-context.component.ts
@@ -20,6 +20,11 @@ import { ComponentTypeNamePipe } from
'../../pipes/component-type-name.pipe';
import { ComponentType } from '../../types';
import { CopyDirective } from '../../directives/copy/copy.directive';
+interface IconMeta {
+ className: string;
+ iconType: 'font-awesome' | 'flowfont';
+}
+
@Component({
selector: 'component-context',
imports: [ComponentTypeNamePipe, CopyDirective],
@@ -27,12 +32,14 @@ import { CopyDirective } from
'../../directives/copy/copy.directive';
styleUrl: './component-context.component.scss'
})
export class ComponentContext {
+ private static readonly DEFAULT_ICON: IconMeta = { className: 'icon-drop',
iconType: 'flowfont' };
+
private _componentType: ComponentType | string | null =
ComponentType.Processor;
- componentIconClass = '';
+ componentIcon: IconMeta = ComponentContext.DEFAULT_ICON;
@Input() set type(type: ComponentType | string | null) {
this._componentType = type;
- this.componentIconClass = this.getIconClassName(type);
+ this.componentIcon = this.getIconMeta(type);
}
get type(): ComponentType | string | null {
@@ -42,29 +49,31 @@ export class ComponentContext {
@Input() id: string | null = null;
@Input() name: string | undefined = '';
- private getIconClassName(type: ComponentType | string | null) {
+ private getIconMeta(type: ComponentType | string | null): IconMeta {
if (type === null) {
- return 'icon-drop';
+ return ComponentContext.DEFAULT_ICON;
}
switch (type) {
case ComponentType.Connection:
- return 'icon-connect';
+ return { className: 'icon-connect', iconType: 'flowfont' };
case ComponentType.Processor:
- return 'icon-processor';
+ return { className: 'icon-processor', iconType: 'flowfont' };
case ComponentType.OutputPort:
- return 'icon-port-out';
+ return { className: 'icon-port-out', iconType: 'flowfont' };
case ComponentType.InputPort:
- return 'icon-port-in';
+ return { className: 'icon-port-in', iconType: 'flowfont' };
case ComponentType.ProcessGroup:
- return 'icon-group';
+ return { className: 'icon-group', iconType: 'flowfont' };
case ComponentType.Funnel:
- return 'icon-funnel';
+ return { className: 'icon-funnel', iconType: 'flowfont' };
case ComponentType.Label:
- return 'icon-label';
+ return { className: 'icon-label', iconType: 'flowfont' };
case ComponentType.RemoteProcessGroup:
- return 'icon-group-remote';
+ return { className: 'icon-group-remote', iconType: 'flowfont'
};
+ case ComponentType.Connector:
+ return { className: 'fa-plug', iconType: 'font-awesome' };
default:
- return 'icon-drop';
+ return ComponentContext.DEFAULT_ICON;
}
}
}
diff --git
a/nifi-frontend/src/main/frontend/libs/shared/src/pipes/component-type-name.pipe.ts
b/nifi-frontend/src/main/frontend/libs/shared/src/pipes/component-type-name.pipe.ts
index 5ce2cea550b..aeced9b968f 100644
---
a/nifi-frontend/src/main/frontend/libs/shared/src/pipes/component-type-name.pipe.ts
+++
b/nifi-frontend/src/main/frontend/libs/shared/src/pipes/component-type-name.pipe.ts
@@ -53,6 +53,8 @@ export class ComponentTypeNamePipe implements PipeTransform {
return 'Remote Process Group';
case ComponentType.ReportingTask:
return 'Reporting Task';
+ case ComponentType.Connector:
+ return 'Connector';
default:
return type;
}