+ @for (message of deduplicatedMessages; track message) {
+
+ {{ message }}
+
+ }
+
+ }
+
+
+
+}
diff --git a/nifi-frontend/src/main/frontend/libs/shared/src/components/banner/banner.component.scss b/nifi-frontend/src/main/frontend/libs/shared/src/components/banner/banner.component.scss
new file mode 100644
index 000000000000..2944f9819474
--- /dev/null
+++ b/nifi-frontend/src/main/frontend/libs/shared/src/components/banner/banner.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/libs/shared/src/components/banner/banner.component.spec.ts b/nifi-frontend/src/main/frontend/libs/shared/src/components/banner/banner.component.spec.ts
new file mode 100644
index 000000000000..c7938235c8b6
--- /dev/null
+++ b/nifi-frontend/src/main/frontend/libs/shared/src/components/banner/banner.component.spec.ts
@@ -0,0 +1,64 @@
+/*
+ * 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 { Banner } from './banner.component';
+
+describe('Banner', () => {
+ let component: Banner;
+ let fixture: ComponentFixture;
+
+ beforeEach(async () => {
+ await TestBed.configureTestingModule({
+ imports: [Banner]
+ }).compileComponents();
+
+ fixture = TestBed.createComponent(Banner);
+ component = fixture.componentInstance;
+ });
+
+ it('should create', () => {
+ fixture.detectChanges();
+ expect(component).toBeTruthy();
+ });
+
+ it('should show single message', () => {
+ fixture.componentRef.setInput('messages', ['Test message']);
+ fixture.detectChanges();
+ const messageElement = fixture.nativeElement.querySelector('[data-qa="banner-message"]');
+ expect(messageElement).toBeTruthy();
+ expect(messageElement.textContent).toContain('Test message');
+ });
+
+ it('should show multiple messages', () => {
+ fixture.componentRef.setInput('messages', ['Test message 1', 'Test message 2']);
+ fixture.detectChanges();
+ const messageElements = fixture.nativeElement.querySelectorAll('[data-qa="banner-message-item"]');
+ expect(messageElements.length).toBe(2);
+ expect(messageElements[0].textContent).toContain('Test message 2');
+ expect(messageElements[1].textContent).toContain('Test message 1');
+ });
+
+ it('should show deduplicated messages', () => {
+ fixture.componentRef.setInput('messages', ['Test message 1', 'Test message 1', 'Test message 2']);
+ fixture.detectChanges();
+ const messageElements = fixture.nativeElement.querySelectorAll('[data-qa="banner-message-item"]');
+ expect(messageElements.length).toBe(2);
+ expect(messageElements[0].textContent).toContain('Test message 2');
+ expect(messageElements[1].textContent).toContain('Test message 1');
+ });
+});
diff --git a/nifi-frontend/src/main/frontend/libs/shared/src/components/banner/banner.component.ts b/nifi-frontend/src/main/frontend/libs/shared/src/components/banner/banner.component.ts
new file mode 100644
index 000000000000..8de6831a1921
--- /dev/null
+++ b/nifi-frontend/src/main/frontend/libs/shared/src/components/banner/banner.component.ts
@@ -0,0 +1,57 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { Component, EventEmitter, HostListener, Input, Output, ViewEncapsulation } from '@angular/core';
+import { StatusVariant } from '../../types';
+import { StatusBanner } from '../status-banner/status-banner.component';
+
+@Component({
+ selector: 'banner',
+ imports: [StatusBanner],
+ templateUrl: './banner.component.html',
+ styleUrl: './banner.component.scss',
+ encapsulation: ViewEncapsulation.None
+})
+export class Banner {
+ @Input() messages: string[] | null = null;
+ @Input() variant: StatusVariant = 'critical';
+ @Input() allowDismiss = true;
+ @Input() allowDismissHotKey?: boolean;
+ @Input() panelClass?: string;
+
+ @Output() dismiss: EventEmitter = new EventEmitter();
+
+ dismissClicked(): void {
+ this.dismiss.next();
+ }
+
+ @HostListener('window:keydown.escape', ['$event'])
+ handleKeyDownEscape(event: Event): void {
+ if (this.allowDismissHotKey) {
+ event.stopPropagation();
+ this.dismissClicked();
+ }
+ }
+
+ get deduplicatedMessages(): string[] {
+ if (!this.messages) {
+ return [];
+ }
+
+ return [...new Set([...this.messages].reverse())];
+ }
+}
diff --git a/nifi-frontend/src/main/frontend/libs/shared/src/components/connector-property-input/connector-property-input.component.spec.ts b/nifi-frontend/src/main/frontend/libs/shared/src/components/connector-property-input/connector-property-input.component.spec.ts
index bbbf71841aeb..924e0bf52ce5 100644
--- a/nifi-frontend/src/main/frontend/libs/shared/src/components/connector-property-input/connector-property-input.component.spec.ts
+++ b/nifi-frontend/src/main/frontend/libs/shared/src/components/connector-property-input/connector-property-input.component.spec.ts
@@ -23,6 +23,7 @@ import { NoopAnimationsModule } from '@angular/platform-browser/animations';
import { MatIconTestingModule } from '@angular/material/icon/testing';
import { ConnectorPropertyInput } from './connector-property-input.component';
+import { StringListOrphansStrippedEvent } from './connector-property-input.types';
import {
AllowableValue,
AssetInfo,
@@ -99,7 +100,8 @@ function makeSecret(overrides: Partial = {}): Secret {
(requestAllowableValues)="onRequestAllowableValues()"
(assetFilesSelected)="onAssetFilesSelected($event)"
(assetDeleteRequested)="onAssetDeleteRequested($event)"
- (dismissFailedUploadRequested)="onDismissFailedUploadRequested($event)">
+ (dismissFailedUploadRequested)="onDismissFailedUploadRequested($event)"
+ (stringListOrphansStripped)="onStringListOrphansStripped($event)">
`
})
@@ -116,6 +118,7 @@ class HostComponent {
assetFilesSelectedSpy = vi.fn();
assetDeleteRequestedSpy = vi.fn();
dismissFailedUploadRequestedSpy = vi.fn();
+ stringListOrphansStrippedSpy = vi.fn();
onRequestAllowableValues(): void {
this.requestSpy();
@@ -132,6 +135,10 @@ class HostComponent {
onDismissFailedUploadRequested(progress: UploadProgressInfo): void {
this.dismissFailedUploadRequestedSpy(progress);
}
+
+ onStringListOrphansStripped(event: StringListOrphansStrippedEvent): void {
+ this.stringListOrphansStrippedSpy(event);
+ }
}
class MockResizeObserver {
@@ -535,6 +542,84 @@ describe('ConnectorPropertyInput', () => {
});
});
+ describe('STRING_LIST multi-select orphan strip', () => {
+ it('emits stringListOrphansStripped once with every removed token after strip', async () => {
+ const property = makeProp({
+ type: 'STRING_LIST',
+ name: 'topics',
+ allowableValues: [makeAllowable('t1', 'T1'), makeAllowable('t2', 'T2')]
+ });
+ const { fixture, host } = await setup({
+ property,
+ initialValue: ['t1', 'gone-a', 'gone-b']
+ });
+
+ fixture.detectChanges();
+ await fixture.whenStable();
+ fixture.detectChanges(false);
+
+ expect(host.stringListOrphansStrippedSpy).toHaveBeenCalledTimes(1);
+ expect(host.stringListOrphansStrippedSpy.mock.calls[0][0]).toEqual({
+ propertyName: 'topics',
+ removed: ['gone-a', 'gone-b']
+ });
+ expect(host.control.value).toEqual(['t1']);
+ });
+
+ it('does not emit stringListOrphansStripped when every selected value is allowable', async () => {
+ const property = makeProp({
+ type: 'STRING_LIST',
+ name: 'topics',
+ allowableValues: [makeAllowable('t1', 'T1'), makeAllowable('t2', 'T2')]
+ });
+ const { fixture, host } = await setup({
+ property,
+ initialValue: ['t1', 't2']
+ });
+
+ fixture.detectChanges();
+ await fixture.whenStable();
+ fixture.detectChanges(false);
+
+ expect(host.stringListOrphansStrippedSpy).not.toHaveBeenCalled();
+ });
+
+ it('coalesces multiple computeSelectOptions passes into one strip emission', async () => {
+ const property = makeProp({
+ type: 'STRING_LIST',
+ name: 'topics',
+ allowableValues: [makeAllowable('t1', 'T1'), makeAllowable('t2', 'T2')]
+ });
+ await TestBed.configureTestingModule({
+ imports: [ConnectorPropertyInput, NoopAnimationsModule, MatIconTestingModule]
+ }).compileComponents();
+
+ const fixture = TestBed.createComponent(ConnectorPropertyInput);
+ const listener = vi.fn();
+ fixture.componentInstance.stringListOrphansStripped.subscribe(listener);
+ fixture.componentRef.setInput('property', property);
+ fixture.componentRef.setInput('dynamicAllowableValuesState', null);
+ fixture.componentInstance.writeValue(['t1', 'gone-a', 'gone-b']);
+
+ const computeSelectOptions = (
+ fixture.componentInstance as unknown as { computeSelectOptions: () => void }
+ ).computeSelectOptions.bind(fixture.componentInstance);
+ computeSelectOptions();
+ computeSelectOptions();
+
+ fixture.detectChanges();
+ await fixture.whenStable();
+ fixture.detectChanges(false);
+
+ expect(listener).toHaveBeenCalledTimes(1);
+ expect(listener.mock.calls[0][0]).toEqual({
+ propertyName: 'topics',
+ removed: ['gone-a', 'gone-b']
+ });
+ expect(fixture.componentInstance.formControl.value).toEqual(['t1']);
+ });
+ });
+
describe('already-hydrated dynamicAllowableValuesState', () => {
it('still emits requestAllowableValues exactly once when values are hydrated before first paint', async () => {
const { host } = await setup({
diff --git a/nifi-frontend/src/main/frontend/libs/shared/src/components/connector-property-input/connector-property-input.component.ts b/nifi-frontend/src/main/frontend/libs/shared/src/components/connector-property-input/connector-property-input.component.ts
index 682256ff96d7..26504bbdb829 100644
--- a/nifi-frontend/src/main/frontend/libs/shared/src/components/connector-property-input/connector-property-input.component.ts
+++ b/nifi-frontend/src/main/frontend/libs/shared/src/components/connector-property-input/connector-property-input.component.ts
@@ -47,6 +47,7 @@ import {
} from '../../types';
import { SearchableSelect } from '../searchable-select/searchable-select.component';
import { AssetUpload } from '../asset-upload/asset-upload.component';
+import { StringListOrphansStrippedEvent } from './connector-property-input.types';
/**
* Form control for a single connector property.
@@ -98,6 +99,14 @@ export class ConnectorPropertyInput implements ControlValueAccessor, DoCheck, On
readonly assetDeleteRequested = output();
readonly dismissFailedUploadRequested = output();
+ /**
+ * Emitted once per strip operation after STRING_LIST multi-select values that
+ * are no longer in the loaded allowable-values list are removed from the form
+ * control (see `computeSelectOptions`). Coalesces multiple `computeSelectOptions`
+ * passes into a single emission via `stringListStripScheduled`.
+ */
+ readonly stringListOrphansStripped = output();
+
formControl = new FormControl();
// Cached options to avoid recomputing on every change detection cycle
@@ -109,6 +118,13 @@ export class ConnectorPropertyInput implements ControlValueAccessor, DoCheck, On
private hasFetchedDynamicValues = false;
private lastSeenPropertyName: string | null = null;
+ /**
+ * Coalesces multiple `computeSelectOptions` passes before a scheduled
+ * STRING_LIST orphan strip runs, so the form control is only rewritten and
+ * `stringListOrphansStripped` is only emitted once per strip pass.
+ */
+ private stringListStripScheduled = false;
+
get parentControl(): FormControl | null {
return this.ngControl?.control as FormControl | null;
}
@@ -484,9 +500,13 @@ export class ConnectorPropertyInput implements ControlValueAccessor, DoCheck, On
}
/**
- * Pure computation of SearchableSelectOptions from whichever source is available.
- * Has no side effects; all reactive updates (provider-rename rewrite, orphan
- * clearance on value change) are handled by separate effects and subscriptions.
+ * Builds SearchableSelectOptions from whichever source is available.
+ *
+ * For STRING_LIST multi-select, may schedule a deferred strip (via
+ * `afterNextRender`) when saved values are not in the loaded allowable list;
+ * that pass coalesces with `stringListStripScheduled` so the form control is
+ * rewritten and `stringListOrphansStripped` is emitted at most once per strip.
+ * Provider-rename rewrite and other reactive updates use separate effects.
*
* - SECRET: options come from availableSecrets, valued by a composite key
* (providerId::providerName::fullyQualifiedName) and grouped by provider
@@ -516,10 +536,55 @@ export class ConnectorPropertyInput implements ControlValueAccessor, DoCheck, On
allowableValues = prop.allowableValues || [];
}
- return allowableValues.map((av) => ({
+ const options: SearchableSelectOption[] = allowableValues.map((av) => ({
value: av.allowableValue.value,
label: av.allowableValue.displayName
}));
+
+ // Surface saved values that are no longer in the loaded allowable list
+ // as disabled "(no longer available)" options so the selection is visible
+ // to the user. For STRING_LIST (multi-select) we additionally strip those
+ // values from the form control on the next render and emit
+ // stringListOrphansStripped so the host can banner the removal -- single
+ // selects keep the orphan option around because the searchable-select
+ // already shows the stale value and the user can pick a replacement.
+ const currentValue = this.formControl.value;
+ if (allowableValues.length > 0 && currentValue !== null && currentValue !== undefined) {
+ const existingOptionValues = new Set(options.map((o) => o.value));
+ const isMultiple = Array.isArray(currentValue);
+ const valuesToCheck = isMultiple ? (currentValue as string[]) : [currentValue as string];
+ const orphanedValues: string[] = [];
+
+ for (const val of valuesToCheck) {
+ if (val && !existingOptionValues.has(val)) {
+ orphanedValues.push(val);
+ options.unshift({
+ value: val,
+ label: `${val} (no longer available)`,
+ disabled: true
+ });
+ }
+ }
+
+ if (isMultiple && orphanedValues.length > 0 && !this.stringListStripScheduled) {
+ this.stringListStripScheduled = true;
+ const validValues = (currentValue as string[]).filter((v) => !orphanedValues.includes(v));
+ const removedSnapshot = [...orphanedValues];
+ const propertyName = prop.name;
+ runInInjectionContext(this.injector, () => {
+ afterNextRender(() => {
+ this.stringListStripScheduled = false;
+ this.formControl.setValue(validValues, { emitEvent: true });
+ this.stringListOrphansStripped.emit({
+ propertyName,
+ removed: removedSnapshot
+ });
+ });
+ });
+ }
+ }
+
+ return options;
}
private computeSecretSelectOptions(): SearchableSelectOption[] {
diff --git a/nifi-frontend/src/main/frontend/libs/shared/src/components/connector-property-input/connector-property-input.types.ts b/nifi-frontend/src/main/frontend/libs/shared/src/components/connector-property-input/connector-property-input.types.ts
new file mode 100644
index 000000000000..00c3c18a75bf
--- /dev/null
+++ b/nifi-frontend/src/main/frontend/libs/shared/src/components/connector-property-input/connector-property-input.types.ts
@@ -0,0 +1,27 @@
+/*
+ * 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.
+ */
+
+/**
+ * Emitted by `connector-property-input` after STRING_LIST multi-select values
+ * that are not in the current allowable-values list have been removed from the
+ * form control. `removed` reports every value stripped during a single strip
+ * pass so the host can render a single banner entry per property.
+ */
+export interface StringListOrphansStrippedEvent {
+ propertyName: string;
+ removed: string[];
+}
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 757c93335f36..e21e60b14cd9 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
@@ -41,6 +41,41 @@
data-qa="step-general-verification-errors">
+ @if (stringListOrphanStripBannerEntries().length > 0) {
+
+
+
+
+ Some previously saved selections are no longer available and have been removed from
+ the following fields:
+
+ @for (entry of stringListOrphanStripBannerEntries(); track entry.propertyName) {
+
+
+ {{ entry.propertyName }}
+
+
+ @for (value of entry.removedVisible; track value) {
+
-}
+
diff --git a/nifi-frontend/src/main/frontend/libs/shared/src/components/connector-wizard/wizard-context-banner/wizard-context-banner.component.spec.ts b/nifi-frontend/src/main/frontend/libs/shared/src/components/connector-wizard/wizard-context-banner/wizard-context-banner.component.spec.ts
index 99a48859515f..c4fd7fde41ce 100644
--- a/nifi-frontend/src/main/frontend/libs/shared/src/components/connector-wizard/wizard-context-banner/wizard-context-banner.component.spec.ts
+++ b/nifi-frontend/src/main/frontend/libs/shared/src/components/connector-wizard/wizard-context-banner/wizard-context-banner.component.spec.ts
@@ -15,9 +15,12 @@
* limitations under the License.
*/
-import { NO_ERRORS_SCHEMA, signal } from '@angular/core';
+import { signal } from '@angular/core';
import { TestBed } from '@angular/core/testing';
+import { By } from '@angular/platform-browser';
import { ConnectorWizardStore } from '../connector-wizard.store';
+import { Banner } from '../../banner/banner.component';
+import { StatusBanner } from '../../status-banner/status-banner.component';
import { WizardContextBanner } from './wizard-context-banner.component';
function createMockStore(initialErrors: string[] = []) {
@@ -30,7 +33,7 @@ function createMockStore(initialErrors: string[] = []) {
interface SetupOptions {
initialErrors?: string[];
- variant?: 'critical' | 'warning' | 'info' | 'success';
+ variant?: 'critical' | 'caution' | 'info' | 'success';
persistOnDestroy?: boolean;
}
@@ -39,8 +42,7 @@ async function setup(options: SetupOptions = {}) {
await TestBed.configureTestingModule({
imports: [WizardContextBanner],
- providers: [{ provide: ConnectorWizardStore, useValue: mockStore }],
- schemas: [NO_ERRORS_SCHEMA]
+ providers: [{ provide: ConnectorWizardStore, useValue: mockStore }]
}).compileComponents();
const fixture = TestBed.createComponent(WizardContextBanner);
@@ -127,9 +129,26 @@ describe('WizardContextBanner', () => {
expect(component.variant()).toBe('critical');
});
- it('accepts warning variant', async () => {
- const { component } = await setup({ variant: 'warning' });
- expect(component.variant()).toBe('warning');
+ it('accepts caution variant', async () => {
+ const { component } = await setup({ variant: 'caution' });
+ expect(component.variant()).toBe('caution');
+ });
+ });
+
+ describe('presentation', () => {
+ it('renders Banner with StatusBanner when store has errors', async () => {
+ const { fixture } = await setup({ initialErrors: ['Save failed'], variant: 'critical' });
+ expect(fixture.debugElement.query(By.directive(Banner))).toBeTruthy();
+ const statusBanner = fixture.debugElement.query(By.directive(StatusBanner));
+ expect(statusBanner).toBeTruthy();
+ expect(statusBanner.componentInstance.variant).toBe('critical');
+ expect(fixture.nativeElement.textContent).toContain('Save failed');
+ });
+
+ it('does not render visible banner content when store has no errors', async () => {
+ const { fixture } = await setup();
+ expect(fixture.nativeElement.querySelector('.banner-container')).toBeNull();
+ expect(fixture.nativeElement.querySelector('.status-banner-container')).toBeNull();
});
});
});
diff --git a/nifi-frontend/src/main/frontend/libs/shared/src/components/connector-wizard/wizard-context-banner/wizard-context-banner.component.ts b/nifi-frontend/src/main/frontend/libs/shared/src/components/connector-wizard/wizard-context-banner/wizard-context-banner.component.ts
index dcf0e89dbff9..7604eabaab1f 100644
--- a/nifi-frontend/src/main/frontend/libs/shared/src/components/connector-wizard/wizard-context-banner/wizard-context-banner.component.ts
+++ b/nifi-frontend/src/main/frontend/libs/shared/src/components/connector-wizard/wizard-context-banner/wizard-context-banner.component.ts
@@ -16,8 +16,8 @@
*/
import { Component, computed, input, OnDestroy, inject } from '@angular/core';
-import { ErrorBanner } from '../../error-banner/error-banner.component';
-type StatusVariant = 'critical' | 'warning' | 'info' | 'success';
+import { Banner } from '../../banner/banner.component';
+import { StatusVariant } from '../../../types';
import { ConnectorWizardStore } from '../connector-wizard.store';
/**
@@ -26,14 +26,13 @@ import { ConnectorWizardStore } from '../connector-wizard.store';
@Component({
selector: 'wizard-context-banner',
standalone: true,
- imports: [ErrorBanner],
+ imports: [Banner],
templateUrl: './wizard-context-banner.component.html',
styleUrl: './wizard-context-banner.component.scss'
})
export class WizardContextBanner implements OnDestroy {
private wizardStore = inject(ConnectorWizardStore);
- /** Preserved for API compatibility; ErrorBanner is always error-styled. */
readonly variant = input('critical');
readonly panelClass = input('mb-4');
readonly persistOnDestroy = input(false);
diff --git a/nifi-frontend/src/main/frontend/libs/shared/src/components/index.ts b/nifi-frontend/src/main/frontend/libs/shared/src/components/index.ts
index 112731a7a430..bfa603090294 100644
--- a/nifi-frontend/src/main/frontend/libs/shared/src/components/index.ts
+++ b/nifi-frontend/src/main/frontend/libs/shared/src/components/index.ts
@@ -29,6 +29,7 @@ export * from './yes-no-dialog/yes-no-dialog.component';
export * from './tooltips/property-hint-tip/property-hint-tip.component';
export * from './tooltips/text-tip/text-tip.component';
export * from './error-banner/error-banner.component';
+export * from './banner/banner.component';
export * from './wizard/wizard.component';
export * from './connector-wizard/connector-wizard.types';
export * from './connector-wizard/connector-wizard.store';
@@ -44,8 +45,11 @@ export * from './connector-step-actions/connector-step-actions.component';
export * from './property-group-card/property-group-card.component';
export * from './side-panel-container/side-panel-container.component';
export * from './connector-property-input/connector-property-input.component';
+export * from './connector-property-input/connector-property-input.types';
export * from './connector-detail-header/connector-detail-header.component';
export * from './searchable-select/searchable-select.component';
export * from './status-badge/status-badge.component';
+export * from './status-banner/status-banner.component';
+export * from './status-banner/status-banner.directives';
export * from './multi-select-option/multi-select-option.component';
export * from './asset-upload/asset-upload.component';
diff --git a/nifi-frontend/src/main/frontend/libs/shared/src/components/status-banner/status-banner.component.html b/nifi-frontend/src/main/frontend/libs/shared/src/components/status-banner/status-banner.component.html
new file mode 100644
index 000000000000..09ff161ea0b1
--- /dev/null
+++ b/nifi-frontend/src/main/frontend/libs/shared/src/components/status-banner/status-banner.component.html
@@ -0,0 +1,39 @@
+
+
+
+
+
+
+
+
+
+
+
+ @if (allowDismiss) {
+
+ }
+
diff --git a/nifi-frontend/src/main/frontend/libs/shared/src/components/status-banner/status-banner.component.scss b/nifi-frontend/src/main/frontend/libs/shared/src/components/status-banner/status-banner.component.scss
new file mode 100644
index 000000000000..2944f9819474
--- /dev/null
+++ b/nifi-frontend/src/main/frontend/libs/shared/src/components/status-banner/status-banner.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/libs/shared/src/components/status-banner/status-banner.component.spec.ts b/nifi-frontend/src/main/frontend/libs/shared/src/components/status-banner/status-banner.component.spec.ts
new file mode 100644
index 000000000000..8a809c5e540f
--- /dev/null
+++ b/nifi-frontend/src/main/frontend/libs/shared/src/components/status-banner/status-banner.component.spec.ts
@@ -0,0 +1,60 @@
+/*
+ * 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 { StatusVariant } from '../../types';
+import { StatusBanner } from './status-banner.component';
+
+describe('StatusBanner', () => {
+ let component: StatusBanner;
+ let fixture: ComponentFixture;
+
+ beforeEach(async () => {
+ await TestBed.configureTestingModule({
+ imports: [StatusBanner]
+ }).compileComponents();
+
+ fixture = TestBed.createComponent(StatusBanner);
+ component = fixture.componentInstance;
+ fixture.detectChanges();
+ });
+
+ it('should create', () => {
+ expect(component).toBeTruthy();
+ });
+
+ it('defaults variant to critical', () => {
+ expect(component.variant).toBe('critical');
+ });
+
+ it('should show the correct FA icon class for each variant', () => {
+ const checkCircle: StatusVariant[] = ['success'];
+ expect(checkCircle.every((variant) => component.getBannerIcon(variant) === 'fa-check-circle-o')).toBe(true);
+
+ const infoCircle: StatusVariant[] = ['info', 'neutral'];
+ expect(infoCircle.every((variant) => component.getBannerIcon(variant) === 'fa-info-circle')).toBe(true);
+
+ const warning: StatusVariant[] = ['critical', 'caution', 'active'];
+ expect(warning.every((variant) => component.getBannerIcon(variant) === 'fa-warning')).toBe(true);
+ });
+
+ it('should emit dismiss event when dismiss is clicked', () => {
+ vi.spyOn(component.dismiss, 'next');
+ component.dismissClicked();
+ expect(component.dismiss.next).toHaveBeenCalled();
+ });
+});
diff --git a/nifi-frontend/src/main/frontend/libs/shared/src/components/status-banner/status-banner.component.ts b/nifi-frontend/src/main/frontend/libs/shared/src/components/status-banner/status-banner.component.ts
new file mode 100644
index 000000000000..a384777d33bc
--- /dev/null
+++ b/nifi-frontend/src/main/frontend/libs/shared/src/components/status-banner/status-banner.component.ts
@@ -0,0 +1,53 @@
+/*
+ * 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 { NgClass } from '@angular/common';
+import { Component, EventEmitter, Input, Output } from '@angular/core';
+import { MatIconButton } from '@angular/material/button';
+import { StatusVariant } from '../../types';
+
+@Component({
+ selector: 'status-banner',
+ imports: [MatIconButton, NgClass],
+ templateUrl: './status-banner.component.html',
+ styleUrl: './status-banner.component.scss'
+})
+export class StatusBanner {
+ @Input() variant: StatusVariant = 'critical';
+ @Input() allowDismiss = true;
+
+ @Output() dismiss: EventEmitter = new EventEmitter();
+
+ getBannerIcon(variant: StatusVariant): string {
+ switch (variant) {
+ case 'success':
+ return 'fa-check-circle-o';
+ case 'info':
+ case 'neutral':
+ return 'fa-info-circle';
+ case 'critical':
+ case 'caution':
+ return 'fa-warning';
+ default:
+ return 'fa-warning';
+ }
+ }
+
+ dismissClicked(): void {
+ this.dismiss.next();
+ }
+}
diff --git a/nifi-frontend/src/main/frontend/libs/shared/src/components/status-banner/status-banner.directives.ts b/nifi-frontend/src/main/frontend/libs/shared/src/components/status-banner/status-banner.directives.ts
new file mode 100644
index 000000000000..46909ccb3ca3
--- /dev/null
+++ b/nifi-frontend/src/main/frontend/libs/shared/src/components/status-banner/status-banner.directives.ts
@@ -0,0 +1,30 @@
+/*
+ * 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 { Directive } from '@angular/core';
+
+@Directive({
+ selector: '[statusBannerTitle]',
+ host: { class: 'status-banner-title text-base font-medium leading-4' }
+})
+export class StatusBannerTitleDirective {}
+
+@Directive({
+ selector: '[statusBannerDescription]',
+ host: { class: 'status-banner-description text-sm leading-[18px]' }
+})
+export class StatusBannerDescriptionDirective {}