This is an automated email from the ASF dual-hosted git repository.
mcgilman pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/nifi.git
The following commit(s) were added to refs/heads/main by this push:
new 1a4389e3c2d NIFI-15815 connector details page (#11254)
1a4389e3c2d is described below
commit 1a4389e3c2da24989de182c095ddf7b3408187e6
Author: Scott Aslan <[email protected]>
AuthorDate: Thu May 21 16:10:43 2026 -0400
NIFI-15815 connector details page (#11254)
* NIFI-15815 connector details page
* address review feedback
* full width details cards
* address other text-base usage
---
.../feature/connectors-routing.module.ts | 5 +
.../connectors-listing.effects.ts | 6 +-
.../connector-configure.component.html | 2 +-
.../connector-configure.component.spec.ts | 46 +-
.../connector-configure.component.ts | 16 +-
.../connector-detail.component.html} | 27 +-
.../connector-detail.component.scss | 16 +
.../connector-detail.component.spec.ts | 484 +++++++++++++++++++++
.../connector-detail.component.ts} | 135 ++----
.../connector-details-content.component.html | 51 +++
.../connector-details-content.component.scss | 16 +
.../connector-details-content.component.spec.ts | 448 +++++++++++++++++++
.../connector-details-content.component.ts | 61 +++
.../connector-configuration-step.component.html | 4 +-
.../connector-wizard.component.html | 6 +-
.../wizard-step-documentation-panel.component.html | 4 +-
16 files changed, 1184 insertions(+), 143 deletions(-)
diff --git
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/feature/connectors-routing.module.ts
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/feature/connectors-routing.module.ts
index 37c302814d5..e05aa5dc4df 100644
---
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/feature/connectors-routing.module.ts
+++
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/feature/connectors-routing.module.ts
@@ -19,6 +19,7 @@ import { NgModule } from '@angular/core';
import { RouterModule, Routes } from '@angular/router';
import { Connectors } from './connectors.component';
import { ConnectorConfigure } from
'../ui/connector-configure/connector-configure.component';
+import { ConnectorDetail } from
'../ui/connector-detail/connector-detail.component';
const routes: Routes = [
{
@@ -30,6 +31,10 @@ const routes: Routes = [
path: ':id/configure',
component: ConnectorConfigure
},
+ {
+ path: ':id/detail',
+ component: ConnectorDetail
+ },
{
path: '',
component: Connectors,
diff --git
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/state/connectors-listing/connectors-listing.effects.ts
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/state/connectors-listing/connectors-listing.effects.ts
index 2548206297f..e3c8d3b764b 100644
---
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/state/connectors-listing/connectors-listing.effects.ts
+++
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/state/connectors-listing/connectors-listing.effects.ts
@@ -449,10 +449,8 @@ export class ConnectorsListingEffects {
() =>
this.actions$.pipe(
ofType(navigateToViewConnectorDetails),
- tap(() => {
- window.alert(
- 'TODO: View connector details is not yet implemented.
This feature will be implemented in a future iteration.'
- );
+ tap((action) => {
+ this.router.navigate(['/connectors', action.id, 'detail']);
})
),
{ dispatch: false }
diff --git
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/ui/connector-configure/connector-configure.component.html
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/ui/connector-configure/connector-configure.component.html
index 20b0b3e70df..6b9f902d6fd 100644
---
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/ui/connector-configure/connector-configure.component.html
+++
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/ui/connector-configure/connector-configure.component.html
@@ -31,7 +31,7 @@
<div class="flex-1 flex items-center justify-center"
data-qa="error-container">
<div class="flex flex-col items-center gap-y-4">
<i class="fa fa-warning error-color text-4xl"
data-qa="error-icon" aria-hidden="true"></i>
- <p class="text-base" data-qa="error-message">{{
errorMessage }}</p>
+ <div data-qa="error-message">{{ errorMessage }}</div>
<button mat-flat-button
(click)="returnToConnectorListing()" data-qa="return-button">
Return to Connectors
</button>
diff --git
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/ui/connector-configure/connector-configure.component.spec.ts
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/ui/connector-configure/connector-configure.component.spec.ts
index 05ac255f948..b47326d9430 100644
---
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/ui/connector-configure/connector-configure.component.spec.ts
+++
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/ui/connector-configure/connector-configure.component.spec.ts
@@ -154,7 +154,8 @@ describe('ConnectorConfigure', () => {
};
const mockConnectorMessageHost = {
- startListening: vi.fn()
+ startListening: vi.fn(),
+ stopListening: vi.fn()
};
const mockClusterConnectionService = {
@@ -290,31 +291,37 @@ describe('ConnectorConfigure', () => {
component = fixture.componentInstance;
});
- it('should sanitize and trust valid URLs', () => {
+ it('should trust valid http/https URLs', () => {
const validUrl = 'http://localhost:4200/custom-config';
const result = (component as unknown as { getFrameSource(url:
string): unknown }).getFrameSource(validUrl);
expect(result).toBeTruthy();
});
- it('should return null when sanitizer returns null', () => {
- vi.spyOn(domSanitizer, 'sanitize').mockReturnValue(null);
+ it('should reject URLs with non-http/https schemes', () => {
+ const getFrameSource = (url: string) =>
+ (component as unknown as { getFrameSource(url: string):
unknown }).getFrameSource(url);
- const url = 'some-invalid-url';
- const result = (component as unknown as { getFrameSource(url:
string): unknown }).getFrameSource(url);
+ expect(getFrameSource('javascript:alert("xss")')).toBeNull();
+ expect(getFrameSource('data:text/html,<h1>hi</h1>')).toBeNull();
+ expect(getFrameSource('ftp://example.com/file')).toBeNull();
+ });
+
+ it('should reject malformed URLs', () => {
+ const result = (component as unknown as { getFrameSource(url:
string): unknown }).getFrameSource(
+ 'not-a-valid-url'
+ );
expect(result).toBeNull();
});
- it('should use two-step sanitization process', () => {
- const sanitizeSpy = vi.spyOn(domSanitizer, 'sanitize');
+ it('should bypass security trust for validated URLs', () => {
const bypassSpy = vi.spyOn(domSanitizer,
'bypassSecurityTrustResourceUrl');
const url = 'http://localhost:4200/custom-config';
(component as unknown as { getFrameSource(url: string): void
}).getFrameSource(url);
- expect(sanitizeSpy).toHaveBeenCalled();
- expect(bypassSpy).toHaveBeenCalled();
+
expect(bypassSpy).toHaveBeenCalledWith(expect.stringContaining(url));
});
});
@@ -519,5 +526,24 @@ describe('ConnectorConfigure', () => {
expect(connectorMessageHost.startListening).not.toHaveBeenCalled();
});
+
+ it('should stop listening before loading the next connector', () => {
+
connectorConfigurationService.getConnector.mockReturnValue(of(mockConnectorWithCustomUrl));
+ fixture = TestBed.createComponent(ConnectorConfigure);
+ component = fixture.componentInstance;
+
+ component.ngOnInit();
+
expect(connectorMessageHost.startListening).toHaveBeenCalledTimes(1);
+
+ const secondConnector: ConnectorEntity = {
+ ...mockConnectorWithCustomUrl,
+ id: 'test-connector-2'
+ };
+
connectorConfigurationService.getConnector.mockReturnValue(of(secondConnector));
+ store.overrideSelector(selectConnectorIdFromRoute,
'test-connector-2');
+ store.refreshState();
+
+ expect(connectorMessageHost.stopListening).toHaveBeenCalled();
+ });
});
});
diff --git
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/ui/connector-configure/connector-configure.component.ts
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/ui/connector-configure/connector-configure.component.ts
index 9795fabc952..3e066f6e1e3 100644
---
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/ui/connector-configure/connector-configure.component.ts
+++
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/ui/connector-configure/connector-configure.component.ts
@@ -15,7 +15,7 @@
* limitations under the License.
*/
-import { Component, DestroyRef, ElementRef, OnInit, SecurityContext,
viewChild, inject } from '@angular/core';
+import { Component, DestroyRef, ElementRef, OnInit, viewChild, inject } from
'@angular/core';
import { HttpErrorResponse } from '@angular/common/http';
import { Router } from '@angular/router';
import { NiFiState } from '../../../../state';
@@ -119,6 +119,7 @@ export class ConnectorConfigure implements OnInit {
.pipe(
filter((connectorId) => connectorId != null),
switchMap((connectorId) => {
+ this.connectorMessageHost.stopListening();
this.loading = true;
this.frameSource = null;
this.connector = null;
@@ -189,13 +190,16 @@ export class ConnectorConfigure implements OnInit {
const connectorId = this.connector?.id;
const urlWithParams = connectorId ?
`${configurationUrl}?connectorId=${connectorId}` : configurationUrl;
- const sanitizedUrl = this.domSanitizer.sanitize(SecurityContext.URL,
urlWithParams);
-
- if (sanitizedUrl) {
- return
this.domSanitizer.bypassSecurityTrustResourceUrl(sanitizedUrl);
+ try {
+ const parsed = new URL(urlWithParams);
+ if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
+ return null;
+ }
+ } catch {
+ return null;
}
- return null;
+ return this.domSanitizer.bypassSecurityTrustResourceUrl(urlWithParams);
}
returnToConnectorListing(): void {
diff --git
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/ui/connector-configure/connector-configure.component.html
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/ui/connector-detail/connector-detail.component.html
similarity index 71%
copy from
nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/ui/connector-configure/connector-configure.component.html
copy to
nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/ui/connector-detail/connector-detail.component.html
index 20b0b3e70df..dab439fb2ec 100644
---
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/ui/connector-configure/connector-configure.component.html
+++
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/ui/connector-detail/connector-detail.component.html
@@ -19,47 +19,48 @@
<header class="nifi-header">
<navigation></navigation>
</header>
- <context-error-banner class="wizard-context-banner"
[context]="ErrorContextKey.CONNECTORS"></context-error-banner>
- <div class="flex flex-1 overflow-hidden">
+ @if (!backNavigation() && !loading && !errorMessage) {
+ <div class="pl-5 pt-2">
+ <a (click)="returnToConnectorListing()"
data-qa="fallback-back-link">
+ <i class="fa fa-arrow-left mr-2"
aria-hidden="true"></i>Installed connectors
+ </a>
+ </div>
+ }
+ <context-error-banner
[context]="ErrorContextKey.CONNECTORS"></context-error-banner>
+ <div class="flex flex-1 min-h-0 overflow-y-auto">
@if (loading) {
- <!-- Loading state -->
<div class="flex-1 flex items-center justify-center">
<span *nifiSpinner="loading" data-qa="loading-spinner"></span>
</div>
} @else if (errorMessage) {
- <!-- Error State (Permission Error, API Error, Not Found, etc.) -->
<div class="flex-1 flex items-center justify-center"
data-qa="error-container">
<div class="flex flex-col items-center gap-y-4">
<i class="fa fa-warning error-color text-4xl"
data-qa="error-icon" aria-hidden="true"></i>
- <p class="text-base" data-qa="error-message">{{
errorMessage }}</p>
+ <div data-qa="error-message">{{ errorMessage }}</div>
<button mat-flat-button
(click)="returnToConnectorListing()" data-qa="return-button">
Return to Connectors
</button>
</div>
</div>
} @else if (frameSource) {
- <!-- Custom configuration UI in iframe -->
<!--
Sandbox flags:
allow-scripts — run the embedded connector app
- allow-forms — submit configuration forms
allow-same-origin — postMessage, style injection, API
calls
allow-popups — open external doc links via
target="_blank"
allow-popups-to-escape-sandbox — opened tabs run without
sandbox restrictions
+ (allow-forms omitted — read-only details view)
-->
<iframe
#iframeRef
class="flex-1 border-none"
[src]="frameSource"
- [title]="'Custom configuration for ' +
(connector?.component?.name || 'connector')"
- sandbox="allow-scripts allow-forms allow-same-origin
allow-popups allow-popups-to-escape-sandbox"
+ [title]="'Custom details for ' + (connector?.component?.name
|| 'connector')"
+ sandbox="allow-scripts allow-same-origin allow-popups
allow-popups-to-escape-sandbox"
referrerpolicy="strict-origin-when-cross-origin"
(load)="systemTokensService.appendStyleSheet(iframeRef)"></iframe>
} @else if (connector) {
- <!-- Generic configuration wizard -->
- <connector-wizard [connector]="connector"
(navigateBack)="returnToConnectorListing()">
- <div wizardHeaderContent>{{ connector.component.name }}</div>
- </connector-wizard>
+ <connector-details-content class="w-full h-full"
[connector]="connector"></connector-details-content>
}
</div>
</div>
diff --git
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/ui/connector-detail/connector-detail.component.scss
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/ui/connector-detail/connector-detail.component.scss
new file mode 100644
index 00000000000..2944f981947
--- /dev/null
+++
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/ui/connector-detail/connector-detail.component.scss
@@ -0,0 +1,16 @@
+/*
+ * 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.
+ */
diff --git
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/ui/connector-detail/connector-detail.component.spec.ts
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/ui/connector-detail/connector-detail.component.spec.ts
new file mode 100644
index 00000000000..9b2a7852824
--- /dev/null
+++
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/ui/connector-detail/connector-detail.component.spec.ts
@@ -0,0 +1,484 @@
+/*
+ * 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 { ConnectorDetail } from './connector-detail.component';
+import { MockStore, provideMockStore } from '@ngrx/store/testing';
+import { SystemTokensService, ConnectorEntity, ConnectorConfigurationService }
from '@nifi/shared';
+import { MockComponent } from 'ng-mocks';
+import { Navigation } from
'../../../../ui/common/navigation/navigation.component';
+import { ConnectorDetailsContent } from
'../connector-details-content/connector-details-content.component';
+import { of, throwError } from 'rxjs';
+import { NO_ERRORS_SCHEMA } from '@angular/core';
+import { provideRouter, Router } from '@angular/router';
+import { selectConnectorIdFromRoute } from
'../../state/connectors-listing/connectors-listing.selectors';
+import { HttpErrorResponse } from '@angular/common/http';
+import { ErrorHelper } from '../../../../service/error-helper.service';
+import { errorFeatureKey } from '../../../../state/error';
+import { initialState as errorInitialState } from
'../../../../state/error/error.reducer';
+import { selectBackNavigation } from
'../../../../state/navigation/navigation.selectors';
+import { ConnectorMessageHost } from
'../../service/connector-message-host.service';
+import { currentUserFeatureKey } from '../../../../state/current-user';
+import * as fromCurrentUser from
'../../../../state/current-user/current-user.reducer';
+import { navigationFeatureKey } from '../../../../state/navigation';
+import { initialState as navigationInitialState } from
'../../../../state/navigation/navigation.reducer';
+import { flowConfigurationFeatureKey } from
'../../../../state/flow-configuration';
+import * as fromFlowConfiguration from
'../../../../state/flow-configuration/flow-configuration.reducer';
+import { clusterSummaryFeatureKey } from '../../../../state/cluster-summary';
+import { initialState as clusterSummaryInitialState } from
'../../../../state/cluster-summary/cluster-summary.reducer';
+import { loginConfigurationFeatureKey } from
'../../../../state/login-configuration';
+import { initialState as loginConfigurationInitialState } from
'../../../../state/login-configuration/login-configuration.reducer';
+import { aboutFeatureKey } from '../../../../state/about';
+import { initialState as aboutInitialState } from
'../../../../state/about/about.reducer';
+import { MatIconTestingModule } from '@angular/material/icon/testing';
+import type { Mocked } from 'vitest';
+
+describe('ConnectorDetail', () => {
+ interface SetupOptions {
+ connectorId?: string;
+ connectorResponse?: ConnectorEntity;
+ errorResponse?: HttpErrorResponse;
+ errorMessage?: string;
+ backNavigation?: { route: string[]; routeBoundary: string[]; context:
string } | null;
+ }
+
+ function createMockConnector(overrides: Partial<ConnectorEntity> = {}):
ConnectorEntity {
+ return {
+ id: 'test-connector-1',
+ uri: 'http://localhost:4200/nifi-api/connectors/test-connector-1',
+ permissions: { canRead: true, canWrite: true },
+ bulletins: [],
+ status: {
+ runStatus: 'RUNNING',
+ validationStatus: 'VALID'
+ },
+ component: {
+ id: 'test-connector-1',
+ name: 'Test Connector',
+ type: 'TestConnector',
+ state: 'RUNNING',
+ bundle: {
+ group: 'org.apache.nifi',
+ artifact: 'test-connector',
+ version: '1.0.0'
+ },
+ managedProcessGroupId: 'pg-1',
+ availableActions: [
+ { name: 'START', description: 'Start action', allowed:
true },
+ { name: 'STOP', description: 'Stop action', allowed: true
},
+ { name: 'CONFIGURE', description: 'Configure action',
allowed: true },
+ { name: 'DELETE', description: 'Delete action', allowed:
true }
+ ]
+ },
+ revision: {
+ version: 1
+ },
+ ...overrides
+ };
+ }
+
+ async function setup(options: SetupOptions = {}) {
+ const {
+ connectorId = 'test-connector-1',
+ connectorResponse = createMockConnector(),
+ errorResponse,
+ errorMessage = 'Connector not found'
+ } = options;
+
+ const mockConnectorConfigurationService = {
+ getConnector: vi.fn()
+ };
+
+ const mockErrorHelper = {
+ getErrorString: vi.fn().mockReturnValue(errorMessage)
+ };
+
+ const mockConnectorMessageHost = {
+ startListening: vi.fn(),
+ stopListening: vi.fn()
+ };
+
+ await TestBed.configureTestingModule({
+ imports: [
+ ConnectorDetail,
+ MockComponent(Navigation),
+ MockComponent(ConnectorDetailsContent),
+ MatIconTestingModule
+ ],
+ providers: [
+ provideRouter([]),
+ provideMockStore({
+ initialState: {
+ [currentUserFeatureKey]: fromCurrentUser.initialState,
+ [navigationFeatureKey]: navigationInitialState,
+ [flowConfigurationFeatureKey]:
fromFlowConfiguration.initialState,
+ [errorFeatureKey]: errorInitialState,
+ [clusterSummaryFeatureKey]: clusterSummaryInitialState,
+ [loginConfigurationFeatureKey]:
loginConfigurationInitialState,
+ [aboutFeatureKey]: aboutInitialState
+ },
+ selectors: [
+ { selector: selectConnectorIdFromRoute, value:
connectorId },
+ { selector: selectBackNavigation, value:
options.backNavigation ?? null }
+ ]
+ }),
+ {
+ provide: SystemTokensService,
+ useValue: {
+ appendStyleSheet: vi.fn()
+ }
+ },
+ {
+ provide: ConnectorConfigurationService,
+ useValue: mockConnectorConfigurationService
+ },
+ {
+ provide: ErrorHelper,
+ useValue: mockErrorHelper
+ },
+ {
+ provide: ConnectorMessageHost,
+ useValue: mockConnectorMessageHost
+ }
+ ],
+ schemas: [NO_ERRORS_SCHEMA]
+ }).compileComponents();
+
+ const store = TestBed.inject(MockStore);
+ const router = TestBed.inject(Router);
+ const errorHelper = TestBed.inject(ErrorHelper) as Mocked<ErrorHelper>;
+
+ if (errorResponse) {
+
mockConnectorConfigurationService.getConnector.mockReturnValue(throwError(() =>
errorResponse));
+ } else {
+
mockConnectorConfigurationService.getConnector.mockReturnValue(of(connectorResponse));
+ }
+
+ const fixture = TestBed.createComponent(ConnectorDetail);
+ const component = fixture.componentInstance;
+ const routerNavigateSpy = vi.spyOn(router, 'navigate');
+ const connectorConfigurationService = TestBed.inject(
+ ConnectorConfigurationService
+ ) as Mocked<ConnectorConfigurationService>;
+ const connectorMessageHost = TestBed.inject(ConnectorMessageHost) as
Mocked<ConnectorMessageHost>;
+
+ return {
+ fixture,
+ component,
+ store,
+ router,
+ connectorConfigurationService,
+ errorHelper,
+ routerNavigateSpy,
+ connectorMessageHost
+ };
+ }
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ describe('Component initialization', () => {
+ it('should create', async () => {
+ const { component, fixture } = await setup();
+ fixture.detectChanges();
+
+ expect(component).toBeTruthy();
+ });
+
+ it('should set loading to true initially', async () => {
+ const { component } = await setup();
+
+ expect(component.loading).toBe(true);
+ });
+
+ it('should set loading to false after connector loads', async () => {
+ const { component } = await setup();
+ component.ngOnInit();
+
+ expect(component.loading).toBe(false);
+ });
+ });
+
+ describe('Custom details URL rendering', () => {
+ it('should set frameSource with sanitized URL and connectorId when
detailsUrl is present', async () => {
+ const connectorWithDetailsUrl = createMockConnector({
+ component: {
+ ...createMockConnector().component,
+ detailsUrl: 'http://localhost:4200/custom-details'
+ }
+ });
+
+ const { component } = await setup({ connectorResponse:
connectorWithDetailsUrl });
+ component.ngOnInit();
+
+ expect(component.frameSource).toBeTruthy();
+ expect((component.frameSource as
any).changingThisBreaksApplicationSecurity).toContain(
+
'http://localhost:4200/custom-details?connectorId=test-connector-1'
+ );
+ expect(component.connector).toEqual(connectorWithDetailsUrl);
+ expect(component.loading).toBe(false);
+ });
+
+ it('should set frameSource to null when detailsUrl is absent', async
() => {
+ const { component } = await setup();
+ component.ngOnInit();
+
+ expect(component.frameSource).toBeNull();
+ expect(component.loading).toBe(false);
+ });
+ });
+
+ describe('URL sanitization', () => {
+ it('should sanitize and trust valid URLs with connectorId appended',
async () => {
+ const connectorWithDetailsUrl = createMockConnector({
+ component: {
+ ...createMockConnector().component,
+ detailsUrl: 'http://localhost:4200/custom-details'
+ }
+ });
+
+ const { component } = await setup({ connectorResponse:
connectorWithDetailsUrl });
+ component.ngOnInit();
+
+ expect(component.frameSource).toBeTruthy();
+ expect((component.frameSource as
any).changingThisBreaksApplicationSecurity).toContain(
+
'http://localhost:4200/custom-details?connectorId=test-connector-1'
+ );
+ });
+
+ it('should reject URLs with non-http/https schemes', async () => {
+ const { component } = await setup();
+ component.connector = createMockConnector();
+
+ expect((component as
any).getFrameSource('javascript:alert("xss")')).toBeNull();
+ expect((component as
any).getFrameSource('data:text/html,<h1>hi</h1>')).toBeNull();
+ expect((component as
any).getFrameSource('ftp://example.com/file')).toBeNull();
+ });
+
+ it('should reject malformed URLs', async () => {
+ const { component } = await setup();
+ component.connector = createMockConnector();
+
+ expect((component as
any).getFrameSource('not-a-valid-url')).toBeNull();
+ });
+ });
+
+ describe('Permission handling', () => {
+ it('should set errorMessage when canRead is false', async () => {
+ const connectorWithoutReadPermission = createMockConnector({
+ permissions: { canRead: false, canWrite: false }
+ });
+
+ const { component } = await setup({ connectorResponse:
connectorWithoutReadPermission });
+ component.ngOnInit();
+
+ expect(component.errorMessage).toBe('Insufficient permissions to
view this connector.');
+ expect(component.frameSource).toBeNull();
+ });
+
+ it('should not set errorMessage when canRead is true', async () => {
+ const { component } = await setup();
+ component.ngOnInit();
+
+ expect(component.errorMessage).toBeNull();
+ });
+ });
+
+ describe('Error handling', () => {
+ it('should set errorMessage on fetch error', async () => {
+ const errorResponse = new HttpErrorResponse({
+ error: 'Connector not found',
+ status: 404,
+ statusText: 'Not Found'
+ });
+ const errorMessage = 'Connector not found';
+
+ const { component, errorHelper } = await setup({
+ errorResponse,
+ errorMessage
+ });
+
+ component.ngOnInit();
+
+
expect(errorHelper.getErrorString).toHaveBeenCalledWith(errorResponse);
+ expect(component.errorMessage).toBe(errorMessage);
+ expect(component.loading).toBe(false);
+ });
+ });
+
+ describe('Route parameter changes', () => {
+ it('should reload connector when route parameter changes', async () =>
{
+ const firstConnector = createMockConnector();
+ const secondConnector = createMockConnector({
+ id: 'test-connector-2',
+ component: {
+ ...createMockConnector().component,
+ detailsUrl: 'http://localhost:4200/custom-details'
+ }
+ });
+
+ const { component, connectorConfigurationService, store } = await
setup({
+ connectorResponse: firstConnector
+ });
+
+ component.ngOnInit();
+ expect(component.connector).toEqual(firstConnector);
+
+
connectorConfigurationService.getConnector.mockReturnValue(of(secondConnector));
+ store.overrideSelector(selectConnectorIdFromRoute,
'test-connector-2');
+ store.refreshState();
+
+
expect(connectorConfigurationService.getConnector).toHaveBeenCalledTimes(2);
+ });
+
+ it('should stop listening before loading the next connector', async ()
=> {
+ const firstConnector = createMockConnector({
+ component: {
+ ...createMockConnector().component,
+ detailsUrl: 'http://localhost:4200/custom-details'
+ }
+ });
+ const secondConnector = createMockConnector({ id:
'test-connector-2' });
+
+ const { component, connectorConfigurationService,
connectorMessageHost, store } = await setup({
+ connectorResponse: firstConnector
+ });
+
+ component.ngOnInit();
+
expect(connectorMessageHost.startListening).toHaveBeenCalledTimes(1);
+
+
connectorConfigurationService.getConnector.mockReturnValue(of(secondConnector));
+ store.overrideSelector(selectConnectorIdFromRoute,
'test-connector-2');
+ store.refreshState();
+
+ expect(connectorMessageHost.stopListening).toHaveBeenCalled();
+ });
+ });
+
+ describe('navigation', () => {
+ it('should navigate to connector listing when returnToConnectorListing
is called', async () => {
+ const { component, routerNavigateSpy } = await setup();
+ component.ngOnInit();
+
+ component.returnToConnectorListing();
+
+ expect(routerNavigateSpy).toHaveBeenCalledWith(['/connectors']);
+ });
+ });
+
+ describe('fallback back link', () => {
+ it('should show fallback back link when no backNavigation, not
loading, and no error', async () => {
+ const { fixture } = await setup({ backNavigation: null });
+ fixture.detectChanges();
+
+ const link =
fixture.nativeElement.querySelector('[data-qa="fallback-back-link"]');
+ expect(link).toBeTruthy();
+ expect(link.textContent).toContain('Installed connectors');
+ });
+
+ it('should navigate to connector listing when fallback back link is
clicked', async () => {
+ const { fixture, routerNavigateSpy } = await setup({
backNavigation: null });
+ fixture.detectChanges();
+
+ const link =
fixture.nativeElement.querySelector('[data-qa="fallback-back-link"]');
+ link.click();
+
+ expect(routerNavigateSpy).toHaveBeenCalledWith(['/connectors']);
+ });
+
+ it('should hide fallback back link when backNavigation exists', async
() => {
+ const { fixture } = await setup({
+ backNavigation: {
+ route: ['/connectors', 'test-connector-1'],
+ routeBoundary: ['/connectors', 'test-connector-1',
'detail'],
+ context: 'connectors'
+ }
+ });
+ fixture.detectChanges();
+
+ const link =
fixture.nativeElement.querySelector('[data-qa="fallback-back-link"]');
+ expect(link).toBeNull();
+ });
+
+ it('should hide fallback back link while loading', async () => {
+ const { component, fixture } = await setup({ backNavigation: null,
connectorId: null as any });
+ fixture.detectChanges();
+
+ expect(component.loading).toBe(true);
+ const link =
fixture.nativeElement.querySelector('[data-qa="fallback-back-link"]');
+ expect(link).toBeNull();
+ });
+
+ it('should hide fallback back link when there is an error', async ()
=> {
+ const errorResponse = new HttpErrorResponse({
+ error: 'Not found',
+ status: 404,
+ statusText: 'Not Found'
+ });
+ const { fixture } = await setup({ backNavigation: null,
errorResponse });
+ fixture.detectChanges();
+
+ const link =
fixture.nativeElement.querySelector('[data-qa="fallback-back-link"]');
+ expect(link).toBeNull();
+ });
+ });
+
+ describe('postMessage host', () => {
+ it('should start listening when connector has custom details URL',
async () => {
+ const connectorWithDetailsUrl = createMockConnector({
+ component: {
+ ...createMockConnector().component,
+ detailsUrl: 'http://localhost:4200/custom-details'
+ }
+ });
+
+ const { component, connectorMessageHost } = await setup({
+ connectorResponse: connectorWithDetailsUrl
+ });
+ component.ngOnInit();
+
+
expect(connectorMessageHost.startListening).toHaveBeenCalledTimes(1);
+ expect(connectorMessageHost.startListening).toHaveBeenCalledWith(
+ expect.objectContaining({
+ expectedOrigin: 'http://localhost:4200',
+ iframeElement: expect.any(Function)
+ })
+ );
+ });
+
+ it('should not start listening when no custom details URL', async ()
=> {
+ const { component, connectorMessageHost } = await setup();
+ component.ngOnInit();
+
+ expect(connectorMessageHost.startListening).not.toHaveBeenCalled();
+ });
+
+ it('should not start listening when connector lacks permissions',
async () => {
+ const noPermissionsConnector = createMockConnector({
+ permissions: { canRead: false, canWrite: false }
+ });
+
+ const { component, connectorMessageHost } = await setup({
+ connectorResponse: noPermissionsConnector
+ });
+ component.ngOnInit();
+
+ expect(connectorMessageHost.startListening).not.toHaveBeenCalled();
+ });
+ });
+});
diff --git
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/ui/connector-configure/connector-configure.component.ts
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/ui/connector-detail/connector-detail.component.ts
similarity index 51%
copy from
nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/ui/connector-configure/connector-configure.component.ts
copy to
nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/ui/connector-detail/connector-detail.component.ts
index 9795fabc952..89ac1e1de69 100644
---
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/ui/connector-configure/connector-configure.component.ts
+++
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/ui/connector-detail/connector-detail.component.ts
@@ -15,7 +15,7 @@
* limitations under the License.
*/
-import { Component, DestroyRef, ElementRef, OnInit, SecurityContext,
viewChild, inject } from '@angular/core';
+import { Component, DestroyRef, ElementRef, OnInit, viewChild, inject } from
'@angular/core';
import { HttpErrorResponse } from '@angular/common/http';
import { Router } from '@angular/router';
import { NiFiState } from '../../../../state';
@@ -24,106 +24,60 @@ import { DomSanitizer, SafeResourceUrl } from
'@angular/platform-browser';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { Navigation } from
'../../../../ui/common/navigation/navigation.component';
import {
- CONNECTOR_MESSAGE_NAMESPACE,
- ConnectorConfigurationService,
- ConnectorEntity,
- ConnectorWizardConfig,
- ConnectorWizard,
- StandardConnectorWizardStore,
- ConnectorWizardStore,
- CONNECTOR_WIZARD_CONFIG,
- ParentToConnectorMessage,
SystemTokensService,
+ ConnectorEntity,
+ ConnectorConfigurationService,
NifiSpinnerDirective
} from '@nifi/shared';
-import { catchError, distinctUntilChanged, filter, switchMap } from
'rxjs/operators';
-import { of } from 'rxjs';
+import { ConnectorDetailsContent } from
'../connector-details-content/connector-details-content.component';
+import { catchError, filter, switchMap } from 'rxjs/operators';
import { selectConnectorIdFromRoute } from
'../../state/connectors-listing/connectors-listing.selectors';
-import { selectDisconnectionAcknowledged } from
'../../../../state/cluster-summary/cluster-summary.selectors';
-import { ClusterConnectionService } from
'../../../../service/cluster-connection.service';
import { MatButton } from '@angular/material/button';
import { ErrorHelper } from '../../../../service/error-helper.service';
import { ContextErrorBanner } from
'../../../../ui/common/context-error-banner/context-error-banner.component';
import { ErrorContextKey } from '../../../../state/error';
+import { selectBackNavigation } from
'../../../../state/navigation/navigation.selectors';
+import { of } from 'rxjs';
import { ConnectorMessageHost } from
'../../service/connector-message-host.service';
-function connectorWizardConfigFactory(): ConnectorWizardConfig {
- const router = inject(Router);
- const clusterConnectionService = inject(ClusterConnectionService);
- return {
- getDisconnectedNodeAcknowledged: () =>
clusterConnectionService.isDisconnectionAcknowledged(),
- onApplySuccess: (connectorId) => {
- if (connectorId) {
- router.navigate(['/connectors', connectorId]);
- } else {
- router.navigate(['/connectors']);
- }
- },
- onNavigateBack: (connectorId: string | null) => {
- if (connectorId) {
- router.navigate(['/connectors', connectorId]);
- } else {
- router.navigate(['/connectors']);
- }
- }
- };
-}
-
@Component({
- selector: 'connector-configure',
- standalone: true,
- imports: [Navigation, ConnectorWizard, MatButton, ContextErrorBanner,
NifiSpinnerDirective],
- templateUrl: './connector-configure.component.html',
- styleUrls: ['./connector-configure.component.scss'],
- providers: [
- StandardConnectorWizardStore,
- { provide: ConnectorWizardStore, useExisting:
StandardConnectorWizardStore },
- { provide: CONNECTOR_WIZARD_CONFIG, useFactory:
connectorWizardConfigFactory }
- ],
- host: {
- class: 'block h-full'
- }
+ selector: 'connector-detail',
+ imports: [Navigation, ConnectorDetailsContent, MatButton,
NifiSpinnerDirective, ContextErrorBanner],
+ templateUrl: './connector-detail.component.html',
+ styleUrls: ['./connector-detail.component.scss']
})
-export class ConnectorConfigure implements OnInit {
+export class ConnectorDetail implements OnInit {
private store = inject<Store<NiFiState>>(Store);
private router = inject(Router);
private domSanitizer = inject(DomSanitizer);
private connectorConfigurationService =
inject(ConnectorConfigurationService);
- private clusterConnectionService = inject(ClusterConnectionService);
- private errorHelper = inject(ErrorHelper);
private destroyRef = inject(DestroyRef);
+ private errorHelper = inject(ErrorHelper);
protected systemTokensService = inject(SystemTokensService);
private connectorMessageHost = inject(ConnectorMessageHost);
readonly iframeRef = viewChild<ElementRef<HTMLIFrameElement>>('iframeRef');
+ backNavigation = this.store.selectSignal(selectBackNavigation);
+ connectorIdFromRoute = this.store.selectSignal(selectConnectorIdFromRoute);
frameSource: SafeResourceUrl | null = null;
connector: ConnectorEntity | null = null;
loading = true;
errorMessage: string | null = null;
- private childConnectorUiReady = false;
+ protected readonly ErrorContextKey = ErrorContextKey;
ngOnInit(): void {
- this.store
- .select(selectDisconnectionAcknowledged)
- .pipe(distinctUntilChanged(), takeUntilDestroyed(this.destroyRef))
- .subscribe(() => {
- if (this.childConnectorUiReady &&
this.connector?.component?.configurationUrl) {
- this.postDisconnectedNodeAcknowledgmentToChild();
- }
- });
-
this.store
.select(selectConnectorIdFromRoute)
.pipe(
filter((connectorId) => connectorId != null),
switchMap((connectorId) => {
+ this.connectorMessageHost.stopListening();
this.loading = true;
this.frameSource = null;
this.connector = null;
this.errorMessage = null;
- this.childConnectorUiReady = false;
return
this.connectorConfigurationService.getConnector(connectorId!).pipe(
catchError((errorResponse: HttpErrorResponse) => {
@@ -144,63 +98,40 @@ export class ConnectorConfigure implements OnInit {
this.loading = false;
this.errorMessage = null;
- if (!connector.permissions.canRead ||
!connector.permissions.canWrite) {
- this.errorMessage = 'Insufficient permissions to configure
this connector.';
+ if (!connector.permissions.canRead) {
+ this.errorMessage = 'Insufficient permissions to view this
connector.';
return;
}
- if (connector.component?.configurationUrl) {
- this.frameSource =
this.getFrameSource(connector.component.configurationUrl);
+ if (connector.component?.detailsUrl) {
+ this.frameSource =
this.getFrameSource(connector.component.detailsUrl);
this.connectorMessageHost.startListening({
destroyRef: this.destroyRef,
- expectedOrigin:
ConnectorMessageHost.extractOrigin(connector.component.configurationUrl),
- iframeElement: () => this.iframeRef()?.nativeElement,
- onConnectorUiReady: () => {
- this.childConnectorUiReady = true;
- this.postDisconnectedNodeAcknowledgmentToChild();
- }
+ expectedOrigin:
ConnectorMessageHost.extractOrigin(connector.component.detailsUrl),
+ iframeElement: () => this.iframeRef()?.nativeElement
});
}
});
}
- private postDisconnectedNodeAcknowledgmentToChild(): void {
- const iframe = this.iframeRef()?.nativeElement;
- const configurationUrl = this.connector?.component?.configurationUrl;
- if (!iframe?.contentWindow || !configurationUrl) {
- return;
- }
- const targetOrigin =
ConnectorMessageHost.extractOrigin(configurationUrl);
- if (!targetOrigin) {
- return;
- }
- const message: ParentToConnectorMessage = {
- namespace: CONNECTOR_MESSAGE_NAMESPACE,
- type: 'disconnected-node-acknowledgment',
- payload: {
- disconnectedNodeAcknowledged:
this.clusterConnectionService.isDisconnectionAcknowledged()
- }
- };
- iframe.contentWindow.postMessage(message, targetOrigin);
- }
-
- private getFrameSource(configurationUrl: string): SafeResourceUrl | null {
+ private getFrameSource(detailsUrl: string): SafeResourceUrl | null {
const connectorId = this.connector?.id;
- const urlWithParams = connectorId ?
`${configurationUrl}?connectorId=${connectorId}` : configurationUrl;
-
- const sanitizedUrl = this.domSanitizer.sanitize(SecurityContext.URL,
urlWithParams);
+ const urlWithParams = connectorId ?
`${detailsUrl}?connectorId=${connectorId}` : detailsUrl;
- if (sanitizedUrl) {
- return
this.domSanitizer.bypassSecurityTrustResourceUrl(sanitizedUrl);
+ try {
+ const parsed = new URL(urlWithParams);
+ if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
+ return null;
+ }
+ } catch {
+ return null;
}
- return null;
+ return this.domSanitizer.bypassSecurityTrustResourceUrl(urlWithParams);
}
returnToConnectorListing(): void {
this.router.navigate(['/connectors']);
}
-
- protected readonly ErrorContextKey = ErrorContextKey;
}
diff --git
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/ui/connector-details-content/connector-details-content.component.html
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/ui/connector-details-content/connector-details-content.component.html
new file mode 100644
index 00000000000..fb913959036
--- /dev/null
+++
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/ui/connector-details-content/connector-details-content.component.html
@@ -0,0 +1,51 @@
+<!--
+ ~ 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.
+ -->
+
+<div class="flex flex-col gap-y-6 px-5 pt-2 pb-5">
+ <!-- Header Section -->
+ <connector-detail-header
[connector]="connector()"></connector-detail-header>
+
+ <!-- Configuration Sections -->
+ @if (connector().component.activeConfiguration) {
+ @if (filteredStepConfigurations().length > 0) {
+ @for (stepConfig of filteredStepConfigurations(); track
stepConfig.configurationStepName) {
+ <section class="flex flex-col gap-y-4" data-qa="step-section">
+ <div class="text-xl font-bold" data-qa="step-name">
+ {{ stepConfig.configurationStepName }}
+ </div>
+
+ @for (group of stepConfig.propertyGroupConfigurations;
track group.propertyGroupName) {
+ <property-group-card
+ [propertyGroup]="group"
+
[hideGroupName]="stepConfig.propertyGroupConfigurations.length === 1">
+ </property-group-card>
+ }
+ </section>
+ }
+ } @else {
+ <div class="flex flex-col items-center justify-center p-8 gap-y-4
tertiary-color" data-qa="no-config-state">
+ <i class="fa fa-info-circle" aria-hidden="true"></i>
+ <p>No configuration steps available for this connector.</p>
+ </div>
+ }
+ } @else {
+ <div class="flex flex-col items-center justify-center p-8 gap-y-4
tertiary-color" data-qa="empty-state">
+ <i class="fa fa-info-circle" aria-hidden="true"></i>
+ <p>No configuration data available.</p>
+ </div>
+ }
+</div>
diff --git
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/ui/connector-details-content/connector-details-content.component.scss
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/ui/connector-details-content/connector-details-content.component.scss
new file mode 100644
index 00000000000..2944f981947
--- /dev/null
+++
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/ui/connector-details-content/connector-details-content.component.scss
@@ -0,0 +1,16 @@
+/*
+ * 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.
+ */
diff --git
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/ui/connector-details-content/connector-details-content.component.spec.ts
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/ui/connector-details-content/connector-details-content.component.spec.ts
new file mode 100644
index 00000000000..c2f1c6ade15
--- /dev/null
+++
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/ui/connector-details-content/connector-details-content.component.spec.ts
@@ -0,0 +1,448 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+import { ConnectorDetailsContent } from
'./connector-details-content.component';
+import { ConnectorEntity, ConnectorState } from '@nifi/shared';
+import { MatIconTestingModule } from '@angular/material/icon/testing';
+import { provideMockStore } from '@ngrx/store/testing';
+import { By } from '@angular/platform-browser';
+
+describe('ConnectorDetailsContent', () => {
+ let component: ConnectorDetailsContent;
+ let fixture: ComponentFixture<ConnectorDetailsContent>;
+
+ const mockConnector: ConnectorEntity = {
+ id: 'test-connector-id',
+ uri: 'http://localhost:4200/nifi-api/connectors/test-connector-id',
+ permissions: {
+ canRead: true,
+ canWrite: true
+ },
+ status: {
+ runStatus: 'RUNNING',
+ validationStatus: 'VALID'
+ },
+ component: {
+ id: 'test-connector-id',
+ name: 'Test Connector',
+ type: 'org.apache.nifi.connector.TestConnector',
+ state: ConnectorState.RUNNING,
+ bundle: {
+ group: 'org.apache.nifi',
+ artifact: 'nifi-test-connector',
+ version: '1.0.0'
+ },
+ activeConfiguration: {
+ configurationStepConfigurations: [
+ {
+ configurationStepName: 'Basic Configuration',
+ dependencies: [],
+ propertyGroupConfigurations: [
+ {
+ propertyGroupName: 'Connection Settings',
+ propertyDescriptors: {
+ hostname: {
+ name: 'hostname',
+ description: 'The hostname to connect
to',
+ type: 'STRING',
+ required: true
+ }
+ },
+ propertyValues: {
+ hostname: {
+ value: 'localhost',
+ valueType: 'STRING_LITERAL'
+ }
+ }
+ }
+ ]
+ }
+ ]
+ },
+ managedProcessGroupId: 'test-pg-id',
+ availableActions: [
+ { name: 'START', description: 'Start action', allowed: true },
+ { name: 'STOP', description: 'Stop action', allowed: true },
+ { name: 'CONFIGURE', description: 'Configure action', allowed:
true },
+ { name: 'DELETE', description: 'Delete action', allowed: true }
+ ]
+ },
+ bulletins: [],
+ revision: {
+ version: 1
+ }
+ };
+
+ beforeEach(async () => {
+ await TestBed.configureTestingModule({
+ imports: [ConnectorDetailsContent, MatIconTestingModule],
+ providers: [provideMockStore()]
+ }).compileComponents();
+
+ fixture = TestBed.createComponent(ConnectorDetailsContent);
+ component = fixture.componentInstance;
+ });
+
+ it('should create', () => {
+ fixture.componentRef.setInput('connector', mockConnector);
+ fixture.detectChanges();
+ expect(component).toBeTruthy();
+ });
+
+ describe('configuration sections', () => {
+ it('should display configuration step sections', () => {
+ fixture.componentRef.setInput('connector', mockConnector);
+ fixture.detectChanges();
+
+ const stepSection =
fixture.nativeElement.querySelector('[data-qa="step-section"]');
+ expect(stepSection).toBeTruthy();
+ });
+
+ it('should display step name', () => {
+ fixture.componentRef.setInput('connector', mockConnector);
+ fixture.detectChanges();
+
+ const stepName =
fixture.nativeElement.querySelector('[data-qa="step-name"]');
+ expect(stepName?.textContent?.trim()).toBe('Basic Configuration');
+ });
+
+ it('should show no-config-state when configuration steps are empty',
() => {
+ const connectorWithEmptyConfig = {
+ ...mockConnector,
+ component: {
+ ...mockConnector.component,
+ activeConfiguration: {
+ configurationStepConfigurations: []
+ }
+ }
+ };
+ fixture.componentRef.setInput('connector',
connectorWithEmptyConfig);
+ fixture.detectChanges();
+
+ const noConfigState =
fixture.nativeElement.querySelector('[data-qa="no-config-state"]');
+ expect(noConfigState).toBeTruthy();
+ });
+
+ it('should show empty-state when no activeConfiguration is present',
() => {
+ const connectorWithoutConfig = {
+ ...mockConnector,
+ component: {
+ ...mockConnector.component,
+ activeConfiguration: undefined
+ }
+ };
+ fixture.componentRef.setInput('connector', connectorWithoutConfig);
+ fixture.detectChanges();
+
+ const emptyState =
fixture.nativeElement.querySelector('[data-qa="empty-state"]');
+ expect(emptyState).toBeTruthy();
+ });
+ });
+
+ describe('hideGroupName binding', () => {
+ it('should set hideGroupName to true when step has a single property
group', () => {
+ fixture.componentRef.setInput('connector', mockConnector);
+ fixture.detectChanges();
+
+ const card =
fixture.nativeElement.querySelector('property-group-card');
+ expect(card).toBeTruthy();
+ const cardDebug =
fixture.debugElement.query(By.css('property-group-card'));
+ expect(cardDebug.componentInstance.hideGroupName()).toBe(true);
+ });
+
+ it('should set hideGroupName to false when step has multiple property
groups', () => {
+ const connectorWithMultipleGroups: any = {
+ ...mockConnector,
+ component: {
+ ...mockConnector.component,
+ activeConfiguration: {
+ configurationStepConfigurations: [
+ {
+ configurationStepName: 'Config Step',
+ dependencies: [],
+ propertyGroupConfigurations: [
+ {
+ propertyGroupName: 'Group 1',
+ propertyDescriptors: {
+ field1: { name: 'field1', type:
'STRING', required: true }
+ },
+ propertyValues: {
+ field1: { value: 'val1',
valueType: 'STRING_LITERAL' }
+ }
+ },
+ {
+ propertyGroupName: 'Group 2',
+ propertyDescriptors: {
+ field2: { name: 'field2', type:
'STRING', required: false }
+ },
+ propertyValues: {
+ field2: { value: 'val2',
valueType: 'STRING_LITERAL' }
+ }
+ }
+ ]
+ }
+ ]
+ }
+ }
+ };
+ fixture.componentRef.setInput('connector',
connectorWithMultipleGroups);
+ fixture.detectChanges();
+
+ const cards =
fixture.debugElement.queryAll(By.css('property-group-card'));
+ expect(cards.length).toBe(2);
+ expect(cards[0].componentInstance.hideGroupName()).toBe(false);
+ expect(cards[1].componentInstance.hideGroupName()).toBe(false);
+ });
+ });
+
+ describe('filteredStepConfigurations', () => {
+ it('should return empty array when connector has no
activeConfiguration', () => {
+ const connectorWithoutConfig: ConnectorEntity = {
+ ...mockConnector,
+ component: {
+ ...mockConnector.component,
+ activeConfiguration: undefined
+ }
+ };
+ fixture.componentRef.setInput('connector', connectorWithoutConfig);
+
+ expect(component.filteredStepConfigurations()).toEqual([]);
+ });
+
+ it('should return all properties when none have dependencies', () => {
+ fixture.componentRef.setInput('connector', mockConnector);
+
+ const filtered = component.filteredStepConfigurations();
+ expect(filtered.length).toBe(1);
+
expect(Object.keys(filtered[0].propertyGroupConfigurations[0].propertyDescriptors)).toContain('hostname');
+ });
+
+ it('should filter out hidden properties based on dependencies', () => {
+ const connectorWithDependencies: any = {
+ ...mockConnector,
+ component: {
+ ...mockConnector.component,
+ activeConfiguration: {
+ configurationStepConfigurations: [
+ {
+ configurationStepName: 'Config Step',
+ dependencies: [],
+ propertyGroupConfigurations: [
+ {
+ propertyGroupName: 'Settings',
+ propertyDescriptors: {
+ enableFeature: {
+ name: 'enableFeature',
+ description: 'Enable feature',
+ type: 'STRING',
+ required: true
+ },
+ featureOption: {
+ name: 'featureOption',
+ description: 'Feature option',
+ type: 'STRING',
+ required: false,
+ dependencies: [
+ { propertyName:
'enableFeature', dependentValues: ['yes'] }
+ ]
+ }
+ },
+ propertyValues: {
+ enableFeature: { value: 'no',
valueType: 'STRING_LITERAL' }
+ }
+ }
+ ]
+ }
+ ]
+ }
+ }
+ };
+ fixture.componentRef.setInput('connector',
connectorWithDependencies);
+
+ const filtered = component.filteredStepConfigurations();
+ const descriptors =
filtered[0].propertyGroupConfigurations[0].propertyDescriptors;
+
+ expect(Object.keys(descriptors)).toContain('enableFeature');
+ expect(Object.keys(descriptors)).not.toContain('featureOption');
+ });
+
+ it('should include visible dependent properties when condition is
met', () => {
+ const connectorWithDependencies: any = {
+ ...mockConnector,
+ component: {
+ ...mockConnector.component,
+ activeConfiguration: {
+ configurationStepConfigurations: [
+ {
+ configurationStepName: 'Config Step',
+ dependencies: [],
+ propertyGroupConfigurations: [
+ {
+ propertyGroupName: 'Settings',
+ propertyDescriptors: {
+ enableFeature: {
+ name: 'enableFeature',
+ description: 'Enable feature',
+ type: 'STRING',
+ required: true
+ },
+ featureOption: {
+ name: 'featureOption',
+ description: 'Feature option',
+ type: 'STRING',
+ required: false,
+ dependencies: [
+ { propertyName:
'enableFeature', dependentValues: ['yes'] }
+ ]
+ }
+ },
+ propertyValues: {
+ enableFeature: { value: 'yes',
valueType: 'STRING_LITERAL' }
+ }
+ }
+ ]
+ }
+ ]
+ }
+ }
+ };
+ fixture.componentRef.setInput('connector',
connectorWithDependencies);
+
+ const filtered = component.filteredStepConfigurations();
+ const descriptors =
filtered[0].propertyGroupConfigurations[0].propertyDescriptors;
+
+ expect(Object.keys(descriptors)).toContain('enableFeature');
+ expect(Object.keys(descriptors)).toContain('featureOption');
+ });
+
+ it('should filter out hidden steps based on step dependencies', () => {
+ const connectorWithStepDependencies: any = {
+ ...mockConnector,
+ component: {
+ ...mockConnector.component,
+ activeConfiguration: {
+ configurationStepConfigurations: [
+ {
+ configurationStepName: 'Step 1',
+ dependencies: [],
+ propertyGroupConfigurations: [
+ {
+ propertyGroupName: 'Settings',
+ propertyDescriptors: {
+ enableAdvanced: {
+ name: 'enableAdvanced',
+ type: 'STRING',
+ required: true
+ }
+ },
+ propertyValues: {
+ enableAdvanced: { value: 'no',
valueType: 'STRING_LITERAL' }
+ }
+ }
+ ]
+ },
+ {
+ configurationStepName: 'Step 2 - Advanced',
+ dependencies: [
+ { stepName: 'Step 1', propertyName:
'enableAdvanced', dependentValues: ['yes'] }
+ ],
+ propertyGroupConfigurations: [
+ {
+ propertyGroupName: 'Advanced Settings',
+ propertyDescriptors: {
+ advancedOption: {
+ name: 'advancedOption',
+ type: 'STRING',
+ required: false
+ }
+ },
+ propertyValues: {}
+ }
+ ]
+ }
+ ]
+ }
+ }
+ };
+ fixture.componentRef.setInput('connector',
connectorWithStepDependencies);
+
+ const filtered = component.filteredStepConfigurations();
+
+ expect(filtered.length).toBe(1);
+ expect(filtered[0].configurationStepName).toBe('Step 1');
+ });
+
+ it('should include visible steps when step dependency is met', () => {
+ const connectorWithStepDependencies: any = {
+ ...mockConnector,
+ component: {
+ ...mockConnector.component,
+ activeConfiguration: {
+ configurationStepConfigurations: [
+ {
+ configurationStepName: 'Step 1',
+ dependencies: [],
+ propertyGroupConfigurations: [
+ {
+ propertyGroupName: 'Settings',
+ propertyDescriptors: {
+ enableAdvanced: {
+ name: 'enableAdvanced',
+ type: 'STRING',
+ required: true
+ }
+ },
+ propertyValues: {
+ enableAdvanced: { value: 'yes',
valueType: 'STRING_LITERAL' }
+ }
+ }
+ ]
+ },
+ {
+ configurationStepName: 'Step 2 - Advanced',
+ dependencies: [
+ { stepName: 'Step 1', propertyName:
'enableAdvanced', dependentValues: ['yes'] }
+ ],
+ propertyGroupConfigurations: [
+ {
+ propertyGroupName: 'Advanced Settings',
+ propertyDescriptors: {
+ advancedOption: {
+ name: 'advancedOption',
+ type: 'STRING',
+ required: false
+ }
+ },
+ propertyValues: {}
+ }
+ ]
+ }
+ ]
+ }
+ }
+ };
+ fixture.componentRef.setInput('connector',
connectorWithStepDependencies);
+
+ const filtered = component.filteredStepConfigurations();
+
+ expect(filtered.length).toBe(2);
+ expect(filtered[0].configurationStepName).toBe('Step 1');
+ expect(filtered[1].configurationStepName).toBe('Step 2 -
Advanced');
+ });
+ });
+});
diff --git
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/ui/connector-details-content/connector-details-content.component.ts
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/ui/connector-details-content/connector-details-content.component.ts
new file mode 100644
index 00000000000..3c5fd0e70d2
--- /dev/null
+++
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/connectors/ui/connector-details-content/connector-details-content.component.ts
@@ -0,0 +1,61 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { Component, computed, input } from '@angular/core';
+
+import {
+ PropertyGroupCard,
+ ConnectorEntity,
+ ConfigurationStepConfiguration,
+ filterPropertyVisibility,
+ ConnectorDetailHeader,
+ getVisibleStepNames
+} from '@nifi/shared';
+
+@Component({
+ selector: 'connector-details-content',
+ imports: [PropertyGroupCard, ConnectorDetailHeader],
+ templateUrl: './connector-details-content.component.html',
+ styleUrls: ['./connector-details-content.component.scss']
+})
+export class ConnectorDetailsContent {
+ connector = input.required<ConnectorEntity>();
+
+ /**
+ * Computed signal that returns configuration steps filtered by step
visibility,
+ * with property groups filtered to only visible properties.
+ * Step visibility is based on step dependencies (a step can depend on
properties from previous steps).
+ * Property visibility is scoped to each step (dependencies can only
reference properties within the same step).
+ */
+ filteredStepConfigurations = computed(() => {
+ const steps =
this.connector().component.activeConfiguration?.configurationStepConfigurations;
+ if (!steps || steps.length === 0) {
+ return [];
+ }
+
+ const stepConfigurations: { [stepName: string]:
ConfigurationStepConfiguration } = {};
+ for (const step of steps) {
+ stepConfigurations[step.configurationStepName] = step;
+ }
+
+ const stepNames = steps.map((s) => s.configurationStepName);
+ const visibleStepNames = getVisibleStepNames(stepNames,
stepConfigurations, {});
+
+ const visibleSteps = steps.filter((step) =>
visibleStepNames.includes(step.configurationStepName));
+ return filterPropertyVisibility(visibleSteps);
+ });
+}
diff --git
a/nifi-frontend/src/main/frontend/libs/shared/src/components/connector-wizard/connector-configuration-step/connector-configuration-step.component.html
b/nifi-frontend/src/main/frontend/libs/shared/src/components/connector-wizard/connector-configuration-step/connector-configuration-step.component.html
index 757c93335f3..92b4573b325 100644
---
a/nifi-frontend/src/main/frontend/libs/shared/src/components/connector-wizard/connector-configuration-step/connector-configuration-step.component.html
+++
b/nifi-frontend/src/main/frontend/libs/shared/src/components/connector-wizard/connector-configuration-step/connector-configuration-step.component.html
@@ -52,7 +52,7 @@
mode="indeterminate"
diameter="40"
data-qa="step-spinner"></mat-progress-spinner>
- <p class="text-base"
data-qa="step-loading-message">Loading step configuration...</p>
+ <div data-qa="step-loading-message">Loading step
configuration...</div>
</div>
} @else {
@let config = stepConfiguration();
@@ -137,7 +137,7 @@
} @else {
<!-- No Data State -->
<div class="flex items-center justify-center p-8">
- <p class="text-base opacity-70">No configuration
data available for this step.</p>
+ <div class="unset">No configuration data available
for this step.</div>
</div>
}
}
diff --git
a/nifi-frontend/src/main/frontend/libs/shared/src/components/connector-wizard/connector-wizard.component.html
b/nifi-frontend/src/main/frontend/libs/shared/src/components/connector-wizard/connector-wizard.component.html
index 89723da6622..258b15e4917 100644
---
a/nifi-frontend/src/main/frontend/libs/shared/src/components/connector-wizard/connector-wizard.component.html
+++
b/nifi-frontend/src/main/frontend/libs/shared/src/components/connector-wizard/connector-wizard.component.html
@@ -29,7 +29,7 @@
<div class="flex-1 flex items-center justify-center"
data-qa="error-container">
<div class="flex flex-col items-center gap-y-4">
<i class="fa fa-warning error-color text-4xl"
data-qa="error-icon" aria-hidden="true"></i>
- <p class="text-base" data-qa="error-message">{{
wizardStore.error() }}</p>
+ <div data-qa="error-message">{{ wizardStore.error()
}}</div>
<button mat-flat-button (click)="navigateBack.emit()"
data-qa="return-button">
<ng-content select="[returnButtonLabel]">Return to
Connectors</ng-content>
</button>
@@ -112,9 +112,9 @@
<div class="flex-1 flex items-center justify-center"
data-qa="no-steps-container">
<div class="flex flex-col items-center gap-4">
<i class="fa fa-info-circle text-4xl"
data-qa="no-steps-icon" aria-hidden="true"></i>
- <p class="text-base" data-qa="no-steps-message">
+ <div class="unset" data-qa="no-steps-message">
No configuration steps are available for this
connector.
- </p>
+ </div>
</div>
</div>
}
diff --git
a/nifi-frontend/src/main/frontend/libs/shared/src/components/connector-wizard/wizard-step-documentation-panel/wizard-step-documentation-panel.component.html
b/nifi-frontend/src/main/frontend/libs/shared/src/components/connector-wizard/wizard-step-documentation-panel/wizard-step-documentation-panel.component.html
index 3607df7507b..4d6c4500652 100644
---
a/nifi-frontend/src/main/frontend/libs/shared/src/components/connector-wizard/wizard-step-documentation-panel/wizard-step-documentation-panel.component.html
+++
b/nifi-frontend/src/main/frontend/libs/shared/src/components/connector-wizard/wizard-step-documentation-panel/wizard-step-documentation-panel.component.html
@@ -36,7 +36,7 @@
} @else if (getErrorMessage()) {
<div class="error-container flex flex-col items-center
justify-center gap-4 p-8" data-qa="markdown-error">
<i class="fa fa-warning error-color text-4xl"
aria-hidden="true"></i>
- <p class="text-base text-center wrap-anywhere">{{
getErrorMessage() }}</p>
+ <div class="text-center wrap-anywhere">{{ getErrorMessage()
}}</div>
</div>
} @else if (getStepDocumentation()) {
<div class="markdown-content" data-qa="markdown-content">
@@ -45,7 +45,7 @@
} @else {
<div class="empty-container flex flex-col items-center
justify-center gap-4 p-8" data-qa="markdown-empty">
<i class="fa fa-info-circle text-4xl opacity-50"
aria-hidden="true"></i>
- <p class="text-base text-center opacity-70">No documentation
available.</p>
+ <div class="text-center unset">No documentation
available.</div>
</div>
}
</div>