This is an automated email from the ASF dual-hosted git repository.

scottyaslan 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 2bd282a487f NIFI-15959: Rendering a banner when a previously selected 
value in a … (#11270)
2bd282a487f is described below

commit 2bd282a487f14c31381a2ccec87e48c5f15c803a
Author: Matt Gilman <[email protected]>
AuthorDate: Fri May 22 15:14:12 2026 -0400

    NIFI-15959: Rendering a banner when a previously selected value in a … 
(#11270)
    
    * NIFI-15959: Rendering a banner when a previously selected value in a 
STRING_LIST property is no longer available. STRING_LIST is handled 
specifically because the UI automatically deselects it unlike single selection.
    
    * NIFI-15959: Addressing review feedback.
---
 .../libs/shared/src/assets/themes/material.scss    |  44 +++++++
 .../src/components/banner/banner.component.html    |  39 +++++++
 .../src/components/banner/banner.component.scss    |  16 +++
 .../src/components/banner/banner.component.spec.ts |  64 +++++++++++
 .../src/components/banner/banner.component.ts      |  57 +++++++++
 .../connector-property-input.component.spec.ts     |  87 +++++++++++++-
 .../connector-property-input.component.ts          |  73 +++++++++++-
 .../connector-property-input.types.ts              |  27 +++++
 .../connector-configuration-step.component.html    |  38 +++++-
 .../connector-configuration-step.component.spec.ts | 127 +++++++++++++++++++++
 .../connector-configuration-step.component.ts      |  61 +++++++++-
 .../wizard-context-banner.component.html           |   6 +-
 .../wizard-context-banner.component.spec.ts        |  33 ++++--
 .../wizard-context-banner.component.ts             |   7 +-
 .../frontend/libs/shared/src/components/index.ts   |   4 +
 .../status-banner/status-banner.component.html     |  39 +++++++
 .../status-banner/status-banner.component.scss     |  16 +++
 .../status-banner/status-banner.component.spec.ts  |  60 ++++++++++
 .../status-banner/status-banner.component.ts       |  53 +++++++++
 .../status-banner/status-banner.directives.ts      |  30 +++++
 20 files changed, 857 insertions(+), 24 deletions(-)

diff --git 
a/nifi-frontend/src/main/frontend/libs/shared/src/assets/themes/material.scss 
b/nifi-frontend/src/main/frontend/libs/shared/src/assets/themes/material.scss
index 5f6e7b73c7c..e84a58b109c 100644
--- 
a/nifi-frontend/src/main/frontend/libs/shared/src/assets/themes/material.scss
+++ 
b/nifi-frontend/src/main/frontend/libs/shared/src/assets/themes/material.scss
@@ -384,6 +384,28 @@
             color: var(--nf-success-contrast);
             background-color: var(--nf-success-variant);
         }
+
+        .fa {
+            &.neutral {
+                color: var(--mat-sys-on-surface);
+            }
+
+            &.critical {
+                color: var(--mat-sys-on-error);
+            }
+
+            &.caution {
+                color: var(--nf-caution-contrast);
+            }
+
+            &.success {
+                color: var(--nf-success-contrast);
+            }
+
+            &.info {
+                color: var(--nf-success-contrast);
+            }
+        }
     }
 }
 
@@ -901,5 +923,27 @@ html {
             color: var(--nf-success-contrast);
             background-color: var(--nf-success-variant);
         }
+
+        .fa {
+            &.neutral {
+                color: var(--mat-sys-on-surface);
+            }
+
+            &.critical {
+                color: var(--mat-sys-on-error);
+            }
+
+            &.caution {
+                color: var(--nf-caution-contrast);
+            }
+
+            &.success {
+                color: var(--nf-success-contrast);
+            }
+
+            &.info {
+                color: var(--nf-success-contrast);
+            }
+        }
     }
 }
diff --git 
a/nifi-frontend/src/main/frontend/libs/shared/src/components/banner/banner.component.html
 
b/nifi-frontend/src/main/frontend/libs/shared/src/components/banner/banner.component.html
new file mode 100644
index 00000000000..da5eca9d884
--- /dev/null
+++ 
b/nifi-frontend/src/main/frontend/libs/shared/src/components/banner/banner.component.html
@@ -0,0 +1,39 @@
+<!--
+  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.
+-->
+
+@if (messages && messages.length > 0) {
+    <div class="banner-container" [class]="panelClass" data-qa="banner">
+        <status-banner [allowDismiss]="allowDismiss" 
(dismiss)="dismissClicked()" [variant]="variant">
+            <div class="flex-1 flex gap-4">
+                @if (messages.length === 1) {
+                    <div class="break-words" data-qa="banner-message">{{ 
messages[0] }}</div>
+                } @else {
+                    <ul
+                        class="list-disc list-inside"
+                        [class.!list-none]="deduplicatedMessages.length === 1"
+                        data-qa="banner-message-list">
+                        @for (message of deduplicatedMessages; track message) {
+                            <li class="break-words" 
data-qa="banner-message-item">
+                                <span>{{ message }}</span>
+                            </li>
+                        }
+                    </ul>
+                }
+            </div>
+        </status-banner>
+    </div>
+}
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 00000000000..2944f981947
--- /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 00000000000..c7938235c8b
--- /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<Banner>;
+
+    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 00000000000..8de6831a192
--- /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<void> = new EventEmitter<void>();
+
+    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 bbbf71841ae..924e0bf52ce 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> = {}): Secret 
{
             (requestAllowableValues)="onRequestAllowableValues()"
             (assetFilesSelected)="onAssetFilesSelected($event)"
             (assetDeleteRequested)="onAssetDeleteRequested($event)"
-            
(dismissFailedUploadRequested)="onDismissFailedUploadRequested($event)">
+            
(dismissFailedUploadRequested)="onDismissFailedUploadRequested($event)"
+            (stringListOrphansStripped)="onStringListOrphansStripped($event)">
         </connector-property-input>
     `
 })
@@ -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 682256ff96d..26504bbdb82 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<AssetInfo>();
     readonly dismissFailedUploadRequested = output<UploadProgressInfo>();
 
+    /**
+     * 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<StringListOrphansStrippedEvent>();
+
     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<string>[] = 
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<string>[] {
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 00000000000..00c3c18a75b
--- /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 92b4573b325..1105fea677a 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">
                 </error-banner>
 
+                @if (stringListOrphanStripBannerEntries().length > 0) {
+                    <div class="mb-5" role="status" aria-live="polite" 
data-qa="string-list-orphan-strip-banner">
+                        <status-banner
+                            variant="caution"
+                            [allowDismiss]="true"
+                            (dismiss)="onDismissStringListOrphanStripBanner()">
+                            <div statusBannerDescription class="flex flex-col 
gap-4">
+                                <p class="m-0" 
data-qa="string-list-orphan-strip-intro">
+                                    Some previously saved selections are no 
longer available and have been removed from
+                                    the following fields:
+                                </p>
+                                @for (entry of 
stringListOrphanStripBannerEntries(); track entry.propertyName) {
+                                    <div
+                                        class="flex flex-col gap-2"
+                                        
[attr.data-qa]="'string-list-orphan-strip-block-' + $index">
+                                        <div class="font-semibold" 
data-qa="string-list-orphan-strip-field-label">
+                                            {{ entry.propertyName }}
+                                        </div>
+                                        <ul class="m-0 flex flex-col gap-1 
pl-5 list-disc list-outside">
+                                            @for (value of 
entry.removedVisible; track value) {
+                                                <li class="break-words">{{ 
value }}</li>
+                                            }
+                                        </ul>
+                                        @if (entry.hiddenRemovedCount > 0) {
+                                            <p class="m-0" 
data-qa="string-list-orphan-strip-truncation">
+                                                … and {{ 
entry.hiddenRemovedCount }} more
+                                            </p>
+                                        }
+                                    </div>
+                                }
+                            </div>
+                        </status-banner>
+                    </div>
+                }
+
                 <div class="font-bold text-xl leading-6 mb-2" 
data-qa="step-name">
                     {{ stepName() }}
                 </div>
@@ -112,7 +147,8 @@
                                                     "
                                                     
(dismissFailedUploadRequested)="
                                                         
onDismissFailedUpload(property.name, $event)
-                                                    ">
+                                                    "
+                                                    
(stringListOrphansStripped)="onStringListOrphansStripped($event)">
                                                 </connector-property-input>
                                             }
                                         }
diff --git 
a/nifi-frontend/src/main/frontend/libs/shared/src/components/connector-wizard/connector-configuration-step/connector-configuration-step.component.spec.ts
 
b/nifi-frontend/src/main/frontend/libs/shared/src/components/connector-wizard/connector-configuration-step/connector-configuration-step.component.spec.ts
index 10b7b12c4eb..1e785529297 100644
--- 
a/nifi-frontend/src/main/frontend/libs/shared/src/components/connector-wizard/connector-configuration-step/connector-configuration-step.component.spec.ts
+++ 
b/nifi-frontend/src/main/frontend/libs/shared/src/components/connector-wizard/connector-configuration-step/connector-configuration-step.component.spec.ts
@@ -17,10 +17,12 @@
 
 import { Component, forwardRef, input, NO_ERRORS_SCHEMA, output, signal } from 
'@angular/core';
 import { TestBed } from '@angular/core/testing';
+import { By } from '@angular/platform-browser';
 import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';
 import { NoopAnimationsModule } from '@angular/platform-browser/animations';
 import { SharedConnectorConfigurationStep } from 
'./connector-configuration-step.component';
 import { ConnectorPropertyInput } from 
'../../connector-property-input/connector-property-input.component';
+import { StatusBanner } from '../../status-banner/status-banner.component';
 import { ConnectorWizardStore } from '../connector-wizard.store';
 import { CONNECTOR_WIZARD_CONFIG } from '../connector-wizard.types';
 import { ConnectorConfigurationService } from 
'../../../services/connector-configuration.service';
@@ -175,6 +177,7 @@ class StubConnectorPropertyInput implements 
ControlValueAccessor {
     readonly assetFilesSelected = output<File[]>();
     readonly assetDeleteRequested = output<AssetInfo>();
     readonly dismissFailedUploadRequested = output<UploadProgressInfo>();
+    readonly stringListOrphansStripped = output<{ propertyName: string; 
removed: string[] }>();
 
     writeValue(): void {
         /* stub */
@@ -455,6 +458,130 @@ describe('SharedConnectorConfigurationStep', () => {
         });
     });
 
+    // ═══════════════════════════════════════════════════════
+    // STRING_LIST orphan strip banner
+    // ═══════════════════════════════════════════════════════
+
+    describe('STRING_LIST orphan strip banner', () => {
+        it('appends one caution-banner entry per strip event with removed 
values', async () => {
+            const { component } = await setup();
+
+            component.onStringListOrphansStripped({
+                propertyName: 'topics',
+                removed: ['gone-a', 'gone-b']
+            });
+            
expect(component.stringListOrphanStripBannerEntries()).toHaveLength(1);
+            const first = component.stringListOrphanStripBannerEntries()[0];
+            expect(first.propertyName).toBe('topics');
+            expect(first.removedVisible).toEqual(['gone-a', 'gone-b']);
+            expect(first.hiddenRemovedCount).toBe(0);
+
+            component.onStringListOrphansStripped({
+                propertyName: 'other',
+                removed: ['x']
+            });
+            
expect(component.stringListOrphanStripBannerEntries()).toHaveLength(2);
+            const second = component.stringListOrphanStripBannerEntries()[1];
+            expect(second.propertyName).toBe('other');
+            expect(second.removedVisible).toEqual(['x']);
+        });
+
+        it('replaces an existing entry when the same property strips a second 
time', async () => {
+            const { component } = await setup();
+
+            component.onStringListOrphansStripped({
+                propertyName: 'topics',
+                removed: ['gone-a']
+            });
+            component.onStringListOrphansStripped({
+                propertyName: 'topics',
+                removed: ['gone-a', 'gone-b']
+            });
+
+            
expect(component.stringListOrphanStripBannerEntries()).toHaveLength(1);
+            
expect(component.stringListOrphanStripBannerEntries()[0].removedVisible).toEqual(['gone-a',
 'gone-b']);
+        });
+
+        it('caps visible removed values and reports hidden count', async () => 
{
+            const { component } = await setup();
+            const removed = Array.from({ length: 25 }, (_, i) => `v-${i}`);
+            component.onStringListOrphansStripped({
+                propertyName: 'p',
+                removed
+            });
+            const entry = component.stringListOrphanStripBannerEntries()[0];
+            expect(entry.removedVisible).toHaveLength(20);
+            expect(entry.hiddenRemovedCount).toBe(5);
+        });
+
+        it('clears orphan strip banner entries on dismiss', async () => {
+            const { component } = await setup();
+
+            component.onStringListOrphansStripped({
+                propertyName: 'a',
+                removed: ['gone']
+            });
+            
expect(component.stringListOrphanStripBannerEntries().length).toBeGreaterThan(0);
+            component.onDismissStringListOrphanStripBanner();
+            expect(component.stringListOrphanStripBannerEntries()).toEqual([]);
+        });
+
+        it('clears orphan strip banner entries when the form is 
(re)initialized', async () => {
+            const { component } = await setup();
+
+            component.onStringListOrphansStripped({
+                propertyName: 'a',
+                removed: ['gone']
+            });
+            
expect(component.stringListOrphanStripBannerEntries().length).toBeGreaterThan(0);
+
+            (component as unknown as { initializeForm: () => void 
}).initializeForm();
+
+            expect(component.stringListOrphanStripBannerEntries()).toEqual([]);
+        });
+
+        it('renders caution StatusBanner in the template when a property 
strips orphans', async () => {
+            const stepConfig = makeStepConfig('test-step', [makeProp('topics', 
{ type: 'STRING_LIST' })]);
+            const { fixture } = await setup({ stepConfig, stepName: 
'test-step' });
+
+            const stub = 
fixture.debugElement.query(By.directive(StubConnectorPropertyInput));
+            stub.componentInstance.stringListOrphansStripped.emit({
+                propertyName: 'topics',
+                removed: ['gone-a', 'gone-b']
+            });
+            fixture.detectChanges();
+
+            
expect(fixture.nativeElement.querySelector('[data-qa="string-list-orphan-strip-banner"]')).toBeTruthy();
+            const statusBanner = 
fixture.debugElement.query(By.directive(StatusBanner));
+            expect(statusBanner).toBeTruthy();
+            expect(statusBanner.componentInstance.variant).toBe('caution');
+            expect(fixture.nativeElement.textContent).toContain('no longer 
available and have been removed');
+            expect(fixture.nativeElement.textContent).toContain('topics');
+            expect(fixture.nativeElement.textContent).toContain('gone-a');
+        });
+
+        it('removes orphan strip banner from the DOM when dismiss is 
triggered', async () => {
+            const stepConfig = makeStepConfig('test-step', [makeProp('topics', 
{ type: 'STRING_LIST' })]);
+            const { fixture } = await setup({ stepConfig, stepName: 
'test-step' });
+
+            const stub = 
fixture.debugElement.query(By.directive(StubConnectorPropertyInput));
+            stub.componentInstance.stringListOrphansStripped.emit({
+                propertyName: 'topics',
+                removed: ['gone']
+            });
+            fixture.detectChanges();
+
+            
expect(fixture.nativeElement.querySelector('[data-qa="string-list-orphan-strip-banner"]')).toBeTruthy();
+
+            const statusBanner = 
fixture.debugElement.query(By.directive(StatusBanner));
+            statusBanner.triggerEventHandler('dismiss', undefined);
+            fixture.detectChanges();
+
+            
expect(fixture.nativeElement.querySelector('[data-qa="string-list-orphan-strip-banner"]')).toBeNull();
+            
expect(fixture.debugElement.query(By.directive(StatusBanner))).toBeNull();
+        });
+    });
+
     // ═══════════════════════════════════════════════════════
     // ASSET / ASSET_LIST initialization shape reconciliation
     // ═══════════════════════════════════════════════════════
diff --git 
a/nifi-frontend/src/main/frontend/libs/shared/src/components/connector-wizard/connector-configuration-step/connector-configuration-step.component.ts
 
b/nifi-frontend/src/main/frontend/libs/shared/src/components/connector-wizard/connector-configuration-step/connector-configuration-step.component.ts
index d4e1be451bb..35902ab1b04 100644
--- 
a/nifi-frontend/src/main/frontend/libs/shared/src/components/connector-wizard/connector-configuration-step/connector-configuration-step.component.ts
+++ 
b/nifi-frontend/src/main/frontend/libs/shared/src/components/connector-wizard/connector-configuration-step/connector-configuration-step.component.ts
@@ -27,7 +27,8 @@ import {
     ChangeDetectorRef,
     viewChild,
     Injector,
-    Signal
+    Signal,
+    signal
 } from '@angular/core';
 import { takeUntilDestroyed, toObservable } from '@angular/core/rxjs-interop';
 import { FormBuilder, FormGroup, ReactiveFormsModule, Validators, ValidatorFn 
} from '@angular/forms';
@@ -42,7 +43,10 @@ import { SidePanelContainerComponent } from 
'../../side-panel-container/side-pan
 import { UploadService } from '../../../services/upload.service';
 import { ConnectorConfigurationService } from 
'../../../services/connector-configuration.service';
 import { ConnectorPropertyInput } from 
'../../connector-property-input/connector-property-input.component';
+import { StringListOrphansStrippedEvent } from 
'../../connector-property-input/connector-property-input.types';
 import { ErrorBanner } from '../../error-banner/error-banner.component';
+import { StatusBanner } from '../../status-banner/status-banner.component';
+import { StatusBannerDescriptionDirective } from 
'../../status-banner/status-banner.directives';
 import { fromValueReference, toValueReference, SecretReferenceOptions } from 
'../../../services/value-reference.helper';
 import {
     AssetInfo,
@@ -75,6 +79,16 @@ import { WizardStepDocumentationPanel } from 
'../wizard-step-documentation-panel
  */
 const VERIFICATION_ERROR_KEY = 'verificationError';
 
+/** Max removed values listed per property before a "… and N more" line. */
+const STRING_LIST_ORPHAN_STRIP_MAX_VISIBLE_VALUES = 20;
+
+/** One caution-banner block: property label plus values stripped from the 
form control. */
+export interface StringListOrphanStripBannerEntry {
+    propertyName: string;
+    removedVisible: string[];
+    hiddenRemovedCount: number;
+}
+
 @Component({
     selector: 'shared-connector-configuration-step',
     standalone: true,
@@ -89,7 +103,9 @@ const VERIFICATION_ERROR_KEY = 'verificationError';
         WizardContextBanner,
         WizardStepDocumentationPanel,
         MatProgressSpinner,
-        ErrorBanner
+        ErrorBanner,
+        StatusBanner,
+        StatusBannerDescriptionDirective
     ],
     templateUrl: './connector-configuration-step.component.html',
     styleUrls: ['./connector-configuration-step.component.scss']
@@ -143,6 +159,12 @@ export class SharedConnectorConfigurationStep implements 
SaveableStep, OnInit, O
     stepForm!: FormGroup;
     formReady = false;
 
+    /**
+     * Local caution-banner blocks when STRING_LIST multi-select auto-strips 
values
+     * no longer in the loaded allowables list (one entry per strip event / 
property).
+     */
+    readonly stringListOrphanStripBannerEntries = 
signal<StringListOrphanStripBannerEntry[]>([]);
+
     // Upload progress tracking (local as it's transient UI state)
     uploadProgressByProperty: Map<string, UploadProgressInfo[]> = new Map();
     private activeUploadSubscriptions: Subscription[] = [];
@@ -343,6 +365,7 @@ export class SharedConnectorConfigurationStep implements 
SaveableStep, OnInit, O
         const stepData = this.stepConfiguration?.();
 
         if (!stepData) {
+            this.stringListOrphanStripBannerEntries.set([]);
             this.stepForm = this.fb.group({});
             this.formReady = true;
             return;
@@ -350,6 +373,7 @@ export class SharedConnectorConfigurationStep implements 
SaveableStep, OnInit, O
 
         // Clear local progress state when re-initializing
         this.uploadProgressByProperty.clear();
+        this.stringListOrphanStripBannerEntries.set([]);
 
         const unsavedValues: Record<string, ConnectorPropertyFormValue> =
             this.wizardStore.unsavedStepValues()[this.stepName()] ?? {};
@@ -1211,6 +1235,39 @@ export class SharedConnectorConfigurationStep implements 
SaveableStep, OnInit, O
         this.wizardStore.setStepVerificationResults({ stepName: 
this.stepName(), results: [] });
     }
 
+    /**
+     * Append (or replace) a caution-banner block when STRING_LIST multi-select
+     * auto-strips values that are no longer in the loaded allowables list.
+     */
+    onStringListOrphansStripped(event: StringListOrphansStrippedEvent): void {
+        const entry = this.toStringListOrphanStripBannerEntry(event);
+        this.stringListOrphanStripBannerEntries.update((prev) => {
+            const existingIndex = prev.findIndex((e) => e.propertyName === 
entry.propertyName);
+            if (existingIndex === -1) {
+                return [...prev, entry];
+            }
+            const next = prev.slice();
+            next[existingIndex] = entry;
+            return next;
+        });
+    }
+
+    onDismissStringListOrphanStripBanner(): void {
+        this.stringListOrphanStripBannerEntries.set([]);
+    }
+
+    private toStringListOrphanStripBannerEntry(
+        event: StringListOrphansStrippedEvent
+    ): StringListOrphanStripBannerEntry {
+        const removedVisible = event.removed.slice(0, 
STRING_LIST_ORPHAN_STRIP_MAX_VISIBLE_VALUES);
+        const hiddenRemovedCount = Math.max(0, event.removed.length - 
removedVisible.length);
+        return {
+            propertyName: event.propertyName,
+            removedVisible,
+            hiddenRemovedCount
+        };
+    }
+
     /**
      * Toggle the documentation panel
      */
diff --git 
a/nifi-frontend/src/main/frontend/libs/shared/src/components/connector-wizard/wizard-context-banner/wizard-context-banner.component.html
 
b/nifi-frontend/src/main/frontend/libs/shared/src/components/connector-wizard/wizard-context-banner/wizard-context-banner.component.html
index 00e66073d2d..c85ae9668e0 100644
--- 
a/nifi-frontend/src/main/frontend/libs/shared/src/components/connector-wizard/wizard-context-banner/wizard-context-banner.component.html
+++ 
b/nifi-frontend/src/main/frontend/libs/shared/src/components/connector-wizard/wizard-context-banner/wizard-context-banner.component.html
@@ -15,8 +15,4 @@
   limitations under the License.
 -->
 
-@if (messages(); as msgs) {
-    <div [class]="panelClass()">
-        <error-banner [messages]="msgs" [allowDismiss]="true" 
(dismiss)="dismiss()"></error-banner>
-    </div>
-}
+<banner [messages]="messages()" [variant]="variant()" (dismiss)="dismiss()" 
[panelClass]="panelClass()"></banner>
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 99a48859515..c4fd7fde41c 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 dcf0e89dbff..7604eabaab1 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<StatusVariant>('critical');
     readonly panelClass = input<string | undefined>('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 112731a7a43..bfa60309029 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 00000000000..09ff161ea0b
--- /dev/null
+++ 
b/nifi-frontend/src/main/frontend/libs/shared/src/components/status-banner/status-banner.component.html
@@ -0,0 +1,39 @@
+<!--
+  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="status-banner-container status-variant p-4 flex gap-x-2 text-sm 
rounded-lg" [class]="variant">
+    <i class="fa fa-2x" [ngClass]="getBannerIcon(variant)" [class]="variant" 
aria-hidden="true"></i>
+    <div
+        class="status-banner-text-container flex flex-col flex-1 gap-y-1 
break-words max-h-[560px] overflow-auto"
+        [class]="variant">
+        <ng-content select="[statusBannerTitle]"></ng-content>
+        <ng-content select="[statusBannerDescription]"></ng-content>
+        <span class="status-banner-unscoped-content">
+            <ng-content></ng-content>
+        </span>
+    </div>
+    @if (allowDismiss) {
+        <button
+            mat-icon-button
+            type="button"
+            class="shrink-0 self-start"
+            aria-label="Dismiss"
+            (click)="dismissClicked()">
+            <i class="fa fa-times" [class]="variant" aria-hidden="true"></i>
+        </button>
+    }
+</div>
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 00000000000..2944f981947
--- /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 00000000000..8a809c5e540
--- /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<StatusBanner>;
+
+    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 00000000000..a384777d33b
--- /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<void> = new EventEmitter<void>();
+
+    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 00000000000..46909ccb3ca
--- /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 {}

Reply via email to