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 5f6e7b73c7c0..e84a58b109c4 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 000000000000..da5eca9d884c --- /dev/null +++ b/nifi-frontend/src/main/frontend/libs/shared/src/components/banner/banner.component.html @@ -0,0 +1,39 @@ + + +@if (messages && messages.length > 0) { + +} diff --git a/nifi-frontend/src/main/frontend/libs/shared/src/components/banner/banner.component.scss b/nifi-frontend/src/main/frontend/libs/shared/src/components/banner/banner.component.scss new file mode 100644 index 000000000000..2944f9819474 --- /dev/null +++ b/nifi-frontend/src/main/frontend/libs/shared/src/components/banner/banner.component.scss @@ -0,0 +1,16 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ diff --git a/nifi-frontend/src/main/frontend/libs/shared/src/components/banner/banner.component.spec.ts b/nifi-frontend/src/main/frontend/libs/shared/src/components/banner/banner.component.spec.ts new file mode 100644 index 000000000000..c7938235c8b6 --- /dev/null +++ b/nifi-frontend/src/main/frontend/libs/shared/src/components/banner/banner.component.spec.ts @@ -0,0 +1,64 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { Banner } from './banner.component'; + +describe('Banner', () => { + let component: Banner; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [Banner] + }).compileComponents(); + + fixture = TestBed.createComponent(Banner); + component = fixture.componentInstance; + }); + + it('should create', () => { + fixture.detectChanges(); + expect(component).toBeTruthy(); + }); + + it('should show single message', () => { + fixture.componentRef.setInput('messages', ['Test message']); + fixture.detectChanges(); + const messageElement = fixture.nativeElement.querySelector('[data-qa="banner-message"]'); + expect(messageElement).toBeTruthy(); + expect(messageElement.textContent).toContain('Test message'); + }); + + it('should show multiple messages', () => { + fixture.componentRef.setInput('messages', ['Test message 1', 'Test message 2']); + fixture.detectChanges(); + const messageElements = fixture.nativeElement.querySelectorAll('[data-qa="banner-message-item"]'); + expect(messageElements.length).toBe(2); + expect(messageElements[0].textContent).toContain('Test message 2'); + expect(messageElements[1].textContent).toContain('Test message 1'); + }); + + it('should show deduplicated messages', () => { + fixture.componentRef.setInput('messages', ['Test message 1', 'Test message 1', 'Test message 2']); + fixture.detectChanges(); + const messageElements = fixture.nativeElement.querySelectorAll('[data-qa="banner-message-item"]'); + expect(messageElements.length).toBe(2); + expect(messageElements[0].textContent).toContain('Test message 2'); + expect(messageElements[1].textContent).toContain('Test message 1'); + }); +}); diff --git a/nifi-frontend/src/main/frontend/libs/shared/src/components/banner/banner.component.ts b/nifi-frontend/src/main/frontend/libs/shared/src/components/banner/banner.component.ts new file mode 100644 index 000000000000..8de6831a1921 --- /dev/null +++ b/nifi-frontend/src/main/frontend/libs/shared/src/components/banner/banner.component.ts @@ -0,0 +1,57 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Component, EventEmitter, HostListener, Input, Output, ViewEncapsulation } from '@angular/core'; +import { StatusVariant } from '../../types'; +import { StatusBanner } from '../status-banner/status-banner.component'; + +@Component({ + selector: 'banner', + imports: [StatusBanner], + templateUrl: './banner.component.html', + styleUrl: './banner.component.scss', + encapsulation: ViewEncapsulation.None +}) +export class Banner { + @Input() messages: string[] | null = null; + @Input() variant: StatusVariant = 'critical'; + @Input() allowDismiss = true; + @Input() allowDismissHotKey?: boolean; + @Input() panelClass?: string; + + @Output() dismiss: EventEmitter = new EventEmitter(); + + dismissClicked(): void { + this.dismiss.next(); + } + + @HostListener('window:keydown.escape', ['$event']) + handleKeyDownEscape(event: Event): void { + if (this.allowDismissHotKey) { + event.stopPropagation(); + this.dismissClicked(); + } + } + + get deduplicatedMessages(): string[] { + if (!this.messages) { + return []; + } + + return [...new Set([...this.messages].reverse())]; + } +} diff --git a/nifi-frontend/src/main/frontend/libs/shared/src/components/connector-property-input/connector-property-input.component.spec.ts b/nifi-frontend/src/main/frontend/libs/shared/src/components/connector-property-input/connector-property-input.component.spec.ts index bbbf71841aeb..924e0bf52ce5 100644 --- a/nifi-frontend/src/main/frontend/libs/shared/src/components/connector-property-input/connector-property-input.component.spec.ts +++ b/nifi-frontend/src/main/frontend/libs/shared/src/components/connector-property-input/connector-property-input.component.spec.ts @@ -23,6 +23,7 @@ import { NoopAnimationsModule } from '@angular/platform-browser/animations'; import { MatIconTestingModule } from '@angular/material/icon/testing'; import { ConnectorPropertyInput } from './connector-property-input.component'; +import { StringListOrphansStrippedEvent } from './connector-property-input.types'; import { AllowableValue, AssetInfo, @@ -99,7 +100,8 @@ function makeSecret(overrides: Partial = {}): Secret { (requestAllowableValues)="onRequestAllowableValues()" (assetFilesSelected)="onAssetFilesSelected($event)" (assetDeleteRequested)="onAssetDeleteRequested($event)" - (dismissFailedUploadRequested)="onDismissFailedUploadRequested($event)"> + (dismissFailedUploadRequested)="onDismissFailedUploadRequested($event)" + (stringListOrphansStripped)="onStringListOrphansStripped($event)"> ` }) @@ -116,6 +118,7 @@ class HostComponent { assetFilesSelectedSpy = vi.fn(); assetDeleteRequestedSpy = vi.fn(); dismissFailedUploadRequestedSpy = vi.fn(); + stringListOrphansStrippedSpy = vi.fn(); onRequestAllowableValues(): void { this.requestSpy(); @@ -132,6 +135,10 @@ class HostComponent { onDismissFailedUploadRequested(progress: UploadProgressInfo): void { this.dismissFailedUploadRequestedSpy(progress); } + + onStringListOrphansStripped(event: StringListOrphansStrippedEvent): void { + this.stringListOrphansStrippedSpy(event); + } } class MockResizeObserver { @@ -535,6 +542,84 @@ describe('ConnectorPropertyInput', () => { }); }); + describe('STRING_LIST multi-select orphan strip', () => { + it('emits stringListOrphansStripped once with every removed token after strip', async () => { + const property = makeProp({ + type: 'STRING_LIST', + name: 'topics', + allowableValues: [makeAllowable('t1', 'T1'), makeAllowable('t2', 'T2')] + }); + const { fixture, host } = await setup({ + property, + initialValue: ['t1', 'gone-a', 'gone-b'] + }); + + fixture.detectChanges(); + await fixture.whenStable(); + fixture.detectChanges(false); + + expect(host.stringListOrphansStrippedSpy).toHaveBeenCalledTimes(1); + expect(host.stringListOrphansStrippedSpy.mock.calls[0][0]).toEqual({ + propertyName: 'topics', + removed: ['gone-a', 'gone-b'] + }); + expect(host.control.value).toEqual(['t1']); + }); + + it('does not emit stringListOrphansStripped when every selected value is allowable', async () => { + const property = makeProp({ + type: 'STRING_LIST', + name: 'topics', + allowableValues: [makeAllowable('t1', 'T1'), makeAllowable('t2', 'T2')] + }); + const { fixture, host } = await setup({ + property, + initialValue: ['t1', 't2'] + }); + + fixture.detectChanges(); + await fixture.whenStable(); + fixture.detectChanges(false); + + expect(host.stringListOrphansStrippedSpy).not.toHaveBeenCalled(); + }); + + it('coalesces multiple computeSelectOptions passes into one strip emission', async () => { + const property = makeProp({ + type: 'STRING_LIST', + name: 'topics', + allowableValues: [makeAllowable('t1', 'T1'), makeAllowable('t2', 'T2')] + }); + await TestBed.configureTestingModule({ + imports: [ConnectorPropertyInput, NoopAnimationsModule, MatIconTestingModule] + }).compileComponents(); + + const fixture = TestBed.createComponent(ConnectorPropertyInput); + const listener = vi.fn(); + fixture.componentInstance.stringListOrphansStripped.subscribe(listener); + fixture.componentRef.setInput('property', property); + fixture.componentRef.setInput('dynamicAllowableValuesState', null); + fixture.componentInstance.writeValue(['t1', 'gone-a', 'gone-b']); + + const computeSelectOptions = ( + fixture.componentInstance as unknown as { computeSelectOptions: () => void } + ).computeSelectOptions.bind(fixture.componentInstance); + computeSelectOptions(); + computeSelectOptions(); + + fixture.detectChanges(); + await fixture.whenStable(); + fixture.detectChanges(false); + + expect(listener).toHaveBeenCalledTimes(1); + expect(listener.mock.calls[0][0]).toEqual({ + propertyName: 'topics', + removed: ['gone-a', 'gone-b'] + }); + expect(fixture.componentInstance.formControl.value).toEqual(['t1']); + }); + }); + describe('already-hydrated dynamicAllowableValuesState', () => { it('still emits requestAllowableValues exactly once when values are hydrated before first paint', async () => { const { host } = await setup({ diff --git a/nifi-frontend/src/main/frontend/libs/shared/src/components/connector-property-input/connector-property-input.component.ts b/nifi-frontend/src/main/frontend/libs/shared/src/components/connector-property-input/connector-property-input.component.ts index 682256ff96d7..26504bbdb829 100644 --- a/nifi-frontend/src/main/frontend/libs/shared/src/components/connector-property-input/connector-property-input.component.ts +++ b/nifi-frontend/src/main/frontend/libs/shared/src/components/connector-property-input/connector-property-input.component.ts @@ -47,6 +47,7 @@ import { } from '../../types'; import { SearchableSelect } from '../searchable-select/searchable-select.component'; import { AssetUpload } from '../asset-upload/asset-upload.component'; +import { StringListOrphansStrippedEvent } from './connector-property-input.types'; /** * Form control for a single connector property. @@ -98,6 +99,14 @@ export class ConnectorPropertyInput implements ControlValueAccessor, DoCheck, On readonly assetDeleteRequested = output(); readonly dismissFailedUploadRequested = output(); + /** + * Emitted once per strip operation after STRING_LIST multi-select values that + * are no longer in the loaded allowable-values list are removed from the form + * control (see `computeSelectOptions`). Coalesces multiple `computeSelectOptions` + * passes into a single emission via `stringListStripScheduled`. + */ + readonly stringListOrphansStripped = output(); + formControl = new FormControl(); // Cached options to avoid recomputing on every change detection cycle @@ -109,6 +118,13 @@ export class ConnectorPropertyInput implements ControlValueAccessor, DoCheck, On private hasFetchedDynamicValues = false; private lastSeenPropertyName: string | null = null; + /** + * Coalesces multiple `computeSelectOptions` passes before a scheduled + * STRING_LIST orphan strip runs, so the form control is only rewritten and + * `stringListOrphansStripped` is only emitted once per strip pass. + */ + private stringListStripScheduled = false; + get parentControl(): FormControl | null { return this.ngControl?.control as FormControl | null; } @@ -484,9 +500,13 @@ export class ConnectorPropertyInput implements ControlValueAccessor, DoCheck, On } /** - * Pure computation of SearchableSelectOptions from whichever source is available. - * Has no side effects; all reactive updates (provider-rename rewrite, orphan - * clearance on value change) are handled by separate effects and subscriptions. + * Builds SearchableSelectOptions from whichever source is available. + * + * For STRING_LIST multi-select, may schedule a deferred strip (via + * `afterNextRender`) when saved values are not in the loaded allowable list; + * that pass coalesces with `stringListStripScheduled` so the form control is + * rewritten and `stringListOrphansStripped` is emitted at most once per strip. + * Provider-rename rewrite and other reactive updates use separate effects. * * - SECRET: options come from availableSecrets, valued by a composite key * (providerId::providerName::fullyQualifiedName) and grouped by provider @@ -516,10 +536,55 @@ export class ConnectorPropertyInput implements ControlValueAccessor, DoCheck, On allowableValues = prop.allowableValues || []; } - return allowableValues.map((av) => ({ + const options: SearchableSelectOption[] = allowableValues.map((av) => ({ value: av.allowableValue.value, label: av.allowableValue.displayName })); + + // Surface saved values that are no longer in the loaded allowable list + // as disabled "(no longer available)" options so the selection is visible + // to the user. For STRING_LIST (multi-select) we additionally strip those + // values from the form control on the next render and emit + // stringListOrphansStripped so the host can banner the removal -- single + // selects keep the orphan option around because the searchable-select + // already shows the stale value and the user can pick a replacement. + const currentValue = this.formControl.value; + if (allowableValues.length > 0 && currentValue !== null && currentValue !== undefined) { + const existingOptionValues = new Set(options.map((o) => o.value)); + const isMultiple = Array.isArray(currentValue); + const valuesToCheck = isMultiple ? (currentValue as string[]) : [currentValue as string]; + const orphanedValues: string[] = []; + + for (const val of valuesToCheck) { + if (val && !existingOptionValues.has(val)) { + orphanedValues.push(val); + options.unshift({ + value: val, + label: `${val} (no longer available)`, + disabled: true + }); + } + } + + if (isMultiple && orphanedValues.length > 0 && !this.stringListStripScheduled) { + this.stringListStripScheduled = true; + const validValues = (currentValue as string[]).filter((v) => !orphanedValues.includes(v)); + const removedSnapshot = [...orphanedValues]; + const propertyName = prop.name; + runInInjectionContext(this.injector, () => { + afterNextRender(() => { + this.stringListStripScheduled = false; + this.formControl.setValue(validValues, { emitEvent: true }); + this.stringListOrphansStripped.emit({ + propertyName, + removed: removedSnapshot + }); + }); + }); + } + } + + return options; } private computeSecretSelectOptions(): SearchableSelectOption[] { diff --git a/nifi-frontend/src/main/frontend/libs/shared/src/components/connector-property-input/connector-property-input.types.ts b/nifi-frontend/src/main/frontend/libs/shared/src/components/connector-property-input/connector-property-input.types.ts new file mode 100644 index 000000000000..00c3c18a75bf --- /dev/null +++ b/nifi-frontend/src/main/frontend/libs/shared/src/components/connector-property-input/connector-property-input.types.ts @@ -0,0 +1,27 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +/** + * Emitted by `connector-property-input` after STRING_LIST multi-select values + * that are not in the current allowable-values list have been removed from the + * form control. `removed` reports every value stripped during a single strip + * pass so the host can render a single banner entry per property. + */ +export interface StringListOrphansStrippedEvent { + propertyName: string; + removed: string[]; +} diff --git a/nifi-frontend/src/main/frontend/libs/shared/src/components/connector-wizard/connector-configuration-step/connector-configuration-step.component.html b/nifi-frontend/src/main/frontend/libs/shared/src/components/connector-wizard/connector-configuration-step/connector-configuration-step.component.html index 757c93335f36..e21e60b14cd9 100644 --- a/nifi-frontend/src/main/frontend/libs/shared/src/components/connector-wizard/connector-configuration-step/connector-configuration-step.component.html +++ b/nifi-frontend/src/main/frontend/libs/shared/src/components/connector-wizard/connector-configuration-step/connector-configuration-step.component.html @@ -41,6 +41,41 @@ data-qa="step-general-verification-errors"> + @if (stringListOrphanStripBannerEntries().length > 0) { +
+ +
+

+ Some previously saved selections are no longer available and have been removed from + the following fields: +

+ @for (entry of stringListOrphanStripBannerEntries(); track entry.propertyName) { +
+
+ {{ entry.propertyName }} +
+
    + @for (value of entry.removedVisible; track value) { +
  • {{ value }}
  • + } +
+ @if (entry.hiddenRemovedCount > 0) { +

+ … and {{ entry.hiddenRemovedCount }} more +

+ } +
+ } +
+
+
+ } +
{{ stepName() }}
@@ -112,7 +147,8 @@ " (dismissFailedUploadRequested)=" onDismissFailedUpload(property.name, $event) - "> + " + (stringListOrphansStripped)="onStringListOrphansStripped($event)"> } } 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 10b7b12c4ebf..1e785529297c 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(); readonly assetDeleteRequested = output(); readonly dismissFailedUploadRequested = output(); + 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 d4e1be451bb4..35902ab1b046 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([]); + // Upload progress tracking (local as it's transient UI state) uploadProgressByProperty: Map = 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 = 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 00e66073d2d1..c85ae9668e07 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) { -
- -
-} + diff --git a/nifi-frontend/src/main/frontend/libs/shared/src/components/connector-wizard/wizard-context-banner/wizard-context-banner.component.spec.ts b/nifi-frontend/src/main/frontend/libs/shared/src/components/connector-wizard/wizard-context-banner/wizard-context-banner.component.spec.ts index 99a48859515f..c4fd7fde41ce 100644 --- a/nifi-frontend/src/main/frontend/libs/shared/src/components/connector-wizard/wizard-context-banner/wizard-context-banner.component.spec.ts +++ b/nifi-frontend/src/main/frontend/libs/shared/src/components/connector-wizard/wizard-context-banner/wizard-context-banner.component.spec.ts @@ -15,9 +15,12 @@ * limitations under the License. */ -import { NO_ERRORS_SCHEMA, signal } from '@angular/core'; +import { signal } from '@angular/core'; import { TestBed } from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; import { ConnectorWizardStore } from '../connector-wizard.store'; +import { Banner } from '../../banner/banner.component'; +import { StatusBanner } from '../../status-banner/status-banner.component'; import { WizardContextBanner } from './wizard-context-banner.component'; function createMockStore(initialErrors: string[] = []) { @@ -30,7 +33,7 @@ function createMockStore(initialErrors: string[] = []) { interface SetupOptions { initialErrors?: string[]; - variant?: 'critical' | 'warning' | 'info' | 'success'; + variant?: 'critical' | 'caution' | 'info' | 'success'; persistOnDestroy?: boolean; } @@ -39,8 +42,7 @@ async function setup(options: SetupOptions = {}) { await TestBed.configureTestingModule({ imports: [WizardContextBanner], - providers: [{ provide: ConnectorWizardStore, useValue: mockStore }], - schemas: [NO_ERRORS_SCHEMA] + providers: [{ provide: ConnectorWizardStore, useValue: mockStore }] }).compileComponents(); const fixture = TestBed.createComponent(WizardContextBanner); @@ -127,9 +129,26 @@ describe('WizardContextBanner', () => { expect(component.variant()).toBe('critical'); }); - it('accepts warning variant', async () => { - const { component } = await setup({ variant: 'warning' }); - expect(component.variant()).toBe('warning'); + it('accepts caution variant', async () => { + const { component } = await setup({ variant: 'caution' }); + expect(component.variant()).toBe('caution'); + }); + }); + + describe('presentation', () => { + it('renders Banner with StatusBanner when store has errors', async () => { + const { fixture } = await setup({ initialErrors: ['Save failed'], variant: 'critical' }); + expect(fixture.debugElement.query(By.directive(Banner))).toBeTruthy(); + const statusBanner = fixture.debugElement.query(By.directive(StatusBanner)); + expect(statusBanner).toBeTruthy(); + expect(statusBanner.componentInstance.variant).toBe('critical'); + expect(fixture.nativeElement.textContent).toContain('Save failed'); + }); + + it('does not render visible banner content when store has no errors', async () => { + const { fixture } = await setup(); + expect(fixture.nativeElement.querySelector('.banner-container')).toBeNull(); + expect(fixture.nativeElement.querySelector('.status-banner-container')).toBeNull(); }); }); }); diff --git a/nifi-frontend/src/main/frontend/libs/shared/src/components/connector-wizard/wizard-context-banner/wizard-context-banner.component.ts b/nifi-frontend/src/main/frontend/libs/shared/src/components/connector-wizard/wizard-context-banner/wizard-context-banner.component.ts index dcf0e89dbff9..7604eabaab1f 100644 --- a/nifi-frontend/src/main/frontend/libs/shared/src/components/connector-wizard/wizard-context-banner/wizard-context-banner.component.ts +++ b/nifi-frontend/src/main/frontend/libs/shared/src/components/connector-wizard/wizard-context-banner/wizard-context-banner.component.ts @@ -16,8 +16,8 @@ */ import { Component, computed, input, OnDestroy, inject } from '@angular/core'; -import { ErrorBanner } from '../../error-banner/error-banner.component'; -type StatusVariant = 'critical' | 'warning' | 'info' | 'success'; +import { Banner } from '../../banner/banner.component'; +import { StatusVariant } from '../../../types'; import { ConnectorWizardStore } from '../connector-wizard.store'; /** @@ -26,14 +26,13 @@ import { ConnectorWizardStore } from '../connector-wizard.store'; @Component({ selector: 'wizard-context-banner', standalone: true, - imports: [ErrorBanner], + imports: [Banner], templateUrl: './wizard-context-banner.component.html', styleUrl: './wizard-context-banner.component.scss' }) export class WizardContextBanner implements OnDestroy { private wizardStore = inject(ConnectorWizardStore); - /** Preserved for API compatibility; ErrorBanner is always error-styled. */ readonly variant = input('critical'); readonly panelClass = input('mb-4'); readonly persistOnDestroy = input(false); diff --git a/nifi-frontend/src/main/frontend/libs/shared/src/components/index.ts b/nifi-frontend/src/main/frontend/libs/shared/src/components/index.ts index 112731a7a430..bfa603090294 100644 --- a/nifi-frontend/src/main/frontend/libs/shared/src/components/index.ts +++ b/nifi-frontend/src/main/frontend/libs/shared/src/components/index.ts @@ -29,6 +29,7 @@ export * from './yes-no-dialog/yes-no-dialog.component'; export * from './tooltips/property-hint-tip/property-hint-tip.component'; export * from './tooltips/text-tip/text-tip.component'; export * from './error-banner/error-banner.component'; +export * from './banner/banner.component'; export * from './wizard/wizard.component'; export * from './connector-wizard/connector-wizard.types'; export * from './connector-wizard/connector-wizard.store'; @@ -44,8 +45,11 @@ export * from './connector-step-actions/connector-step-actions.component'; export * from './property-group-card/property-group-card.component'; export * from './side-panel-container/side-panel-container.component'; export * from './connector-property-input/connector-property-input.component'; +export * from './connector-property-input/connector-property-input.types'; export * from './connector-detail-header/connector-detail-header.component'; export * from './searchable-select/searchable-select.component'; export * from './status-badge/status-badge.component'; +export * from './status-banner/status-banner.component'; +export * from './status-banner/status-banner.directives'; export * from './multi-select-option/multi-select-option.component'; export * from './asset-upload/asset-upload.component'; diff --git a/nifi-frontend/src/main/frontend/libs/shared/src/components/status-banner/status-banner.component.html b/nifi-frontend/src/main/frontend/libs/shared/src/components/status-banner/status-banner.component.html new file mode 100644 index 000000000000..09ff161ea0b1 --- /dev/null +++ b/nifi-frontend/src/main/frontend/libs/shared/src/components/status-banner/status-banner.component.html @@ -0,0 +1,39 @@ + + +
+ +
+ + + + + +
+ @if (allowDismiss) { + + } +
diff --git a/nifi-frontend/src/main/frontend/libs/shared/src/components/status-banner/status-banner.component.scss b/nifi-frontend/src/main/frontend/libs/shared/src/components/status-banner/status-banner.component.scss new file mode 100644 index 000000000000..2944f9819474 --- /dev/null +++ b/nifi-frontend/src/main/frontend/libs/shared/src/components/status-banner/status-banner.component.scss @@ -0,0 +1,16 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ diff --git a/nifi-frontend/src/main/frontend/libs/shared/src/components/status-banner/status-banner.component.spec.ts b/nifi-frontend/src/main/frontend/libs/shared/src/components/status-banner/status-banner.component.spec.ts new file mode 100644 index 000000000000..8a809c5e540f --- /dev/null +++ b/nifi-frontend/src/main/frontend/libs/shared/src/components/status-banner/status-banner.component.spec.ts @@ -0,0 +1,60 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { StatusVariant } from '../../types'; +import { StatusBanner } from './status-banner.component'; + +describe('StatusBanner', () => { + let component: StatusBanner; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [StatusBanner] + }).compileComponents(); + + fixture = TestBed.createComponent(StatusBanner); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); + + it('defaults variant to critical', () => { + expect(component.variant).toBe('critical'); + }); + + it('should show the correct FA icon class for each variant', () => { + const checkCircle: StatusVariant[] = ['success']; + expect(checkCircle.every((variant) => component.getBannerIcon(variant) === 'fa-check-circle-o')).toBe(true); + + const infoCircle: StatusVariant[] = ['info', 'neutral']; + expect(infoCircle.every((variant) => component.getBannerIcon(variant) === 'fa-info-circle')).toBe(true); + + const warning: StatusVariant[] = ['critical', 'caution', 'active']; + expect(warning.every((variant) => component.getBannerIcon(variant) === 'fa-warning')).toBe(true); + }); + + it('should emit dismiss event when dismiss is clicked', () => { + vi.spyOn(component.dismiss, 'next'); + component.dismissClicked(); + expect(component.dismiss.next).toHaveBeenCalled(); + }); +}); diff --git a/nifi-frontend/src/main/frontend/libs/shared/src/components/status-banner/status-banner.component.ts b/nifi-frontend/src/main/frontend/libs/shared/src/components/status-banner/status-banner.component.ts new file mode 100644 index 000000000000..a384777d33bc --- /dev/null +++ b/nifi-frontend/src/main/frontend/libs/shared/src/components/status-banner/status-banner.component.ts @@ -0,0 +1,53 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { NgClass } from '@angular/common'; +import { Component, EventEmitter, Input, Output } from '@angular/core'; +import { MatIconButton } from '@angular/material/button'; +import { StatusVariant } from '../../types'; + +@Component({ + selector: 'status-banner', + imports: [MatIconButton, NgClass], + templateUrl: './status-banner.component.html', + styleUrl: './status-banner.component.scss' +}) +export class StatusBanner { + @Input() variant: StatusVariant = 'critical'; + @Input() allowDismiss = true; + + @Output() dismiss: EventEmitter = new EventEmitter(); + + getBannerIcon(variant: StatusVariant): string { + switch (variant) { + case 'success': + return 'fa-check-circle-o'; + case 'info': + case 'neutral': + return 'fa-info-circle'; + case 'critical': + case 'caution': + return 'fa-warning'; + default: + return 'fa-warning'; + } + } + + dismissClicked(): void { + this.dismiss.next(); + } +} diff --git a/nifi-frontend/src/main/frontend/libs/shared/src/components/status-banner/status-banner.directives.ts b/nifi-frontend/src/main/frontend/libs/shared/src/components/status-banner/status-banner.directives.ts new file mode 100644 index 000000000000..46909ccb3ca3 --- /dev/null +++ b/nifi-frontend/src/main/frontend/libs/shared/src/components/status-banner/status-banner.directives.ts @@ -0,0 +1,30 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Directive } from '@angular/core'; + +@Directive({ + selector: '[statusBannerTitle]', + host: { class: 'status-banner-title text-base font-medium leading-4' } +}) +export class StatusBannerTitleDirective {} + +@Directive({ + selector: '[statusBannerDescription]', + host: { class: 'status-banner-description text-sm leading-[18px]' } +}) +export class StatusBannerDescriptionDirective {}