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 {}