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

mcgilman pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/nifi.git


The following commit(s) were added to refs/heads/main by this push:
     new c48597468a3 NIFI-16352 - Disable Component State single-entry clear 
when the listing is partial (#11685)
c48597468a3 is described below

commit c48597468a343a39701e04ec3e404a4925a32df3
Author: Rob Fellows <[email protected]>
AuthorDate: Tue Sep 29 15:22:58 2026 -0400

    NIFI-16352 - Disable Component State single-entry clear when the listing is 
partial (#11685)
---
 .../component-state/component-state.component.html |  13 +-
 .../component-state.component.spec.ts              | 205 ++++++++++++++++++++-
 .../component-state/component-state.component.ts   |  68 +++----
 3 files changed, 237 insertions(+), 49 deletions(-)

diff --git 
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/component-state/component-state.component.html
 
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/component-state/component-state.component.html
index dc9c8fa8b31..ec32bc3fb20 100644
--- 
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/component-state/component-state.component.html
+++ 
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/component-state/component-state.component.html
@@ -32,7 +32,6 @@
                     {{ stateDescription }}
                 </div>
             </div>
-            @let dropStateKeySupported = (dropStateKeySupported$ | async)!;
             <div class="flex flex-col">
                 <div>
                     State Deletion Policy
@@ -43,7 +42,7 @@
                         tooltipInputData="In order to clear state this 
component must be stopped or disabled."></i>
                 </div>
                 <div class="tertiary-color font-medium">
-                    @if (dropStateKeySupported) {
+                    @if (dropStateKeySupported()) {
                         When clearing state this component supports deleting 
the entire state or individual entries.
                     } @else {
                         When clearing state this component only supports 
deleting the entire state.
@@ -59,7 +58,7 @@
                                 <mat-label>Filter</mat-label>
                                 <input matInput type="text" class="small" 
formControlName="filterTerm" />
                             </mat-form-field>
-                            @if (canClear && totalEntries > 0) {
+                            @if (canClear() && totalEntries > 0) {
                                 <button type="button" [disabled]="isClearing" 
(click)="clearState()" mat-button>
                                     <span *nifiSpinner="isClearing">Clear 
state</span>
                                 </button>
@@ -141,6 +140,7 @@
                                             <td class="mat-mdc-cell 
mdc-data-table__cell action-column">
                                                 <button
                                                     [disabled]="clearing()"
+                                                    
data-qa="component-state-clear-entry"
                                                     mat-icon-button
                                                     color="primary"
                                                     
(click)="clearComponentStateEntry(item)"
@@ -157,7 +157,12 @@
                 </div>
             </div>
             @if (partialResults) {
-                <div class="-mt-3">Showing partial results</div>
+                <div class="-mt-3" 
data-qa="component-state-partial-results-message">
+                    Showing partial results.
+                    @if (dropStateKeySupported()) {
+                        Individual entries cannot be cleared while only 
partial results are shown.
+                    }
+                </div>
             }
         </div>
     </mat-dialog-content>
diff --git 
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/component-state/component-state.component.spec.ts
 
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/component-state/component-state.component.spec.ts
index 222fa901c3f..1415a0216f3 100644
--- 
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/component-state/component-state.component.spec.ts
+++ 
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/component-state/component-state.component.spec.ts
@@ -16,7 +16,9 @@
  */
 
 import { ComponentFixture, TestBed } from '@angular/core/testing';
+import { By } from '@angular/platform-browser';
 import { NoopAnimationsModule } from '@angular/platform-browser/animations';
+import { CdkVirtualScrollViewport } from '@angular/cdk/scrolling';
 import { MockStore, provideMockStore } from '@ngrx/store/testing';
 import { MatDialogRef } from '@angular/material/dialog';
 import { FormBuilder } from '@angular/forms';
@@ -114,6 +116,22 @@ describe('ComponentStateDialog', () => {
         fixture.detectChanges();
     });
 
+    async function renderVirtualRows(): Promise<void> {
+        const viewportDe = 
fixture.debugElement.query(By.directive(CdkVirtualScrollViewport));
+        const viewport = viewportDe.componentInstance as 
CdkVirtualScrollViewport;
+        Object.defineProperty(viewportDe.nativeElement, 'clientHeight', {
+            configurable: true,
+            get: () => 400
+        });
+        Object.defineProperty(viewportDe.nativeElement, 'clientWidth', {
+            configurable: true,
+            get: () => 800
+        });
+        viewport.checkViewportSize();
+        await fixture.whenStable();
+        fixture.detectChanges();
+    }
+
     it('should create', () => {
         expect(component).toBeTruthy();
     });
@@ -138,8 +156,30 @@ describe('ComponentStateDialog', () => {
         });
 
         it('should include actions column when canClear and 
dropStateKeySupported are true', () => {
-            expect(component.displayedColumns).toContain('actions');
-            expect(component.canClear).toBe(true);
+            expect(component.displayedColumns).toEqual(['key', 'value', 
'actions']);
+            expect(component.canClear()).toBe(true);
+            expect(fixture.nativeElement.querySelector('thead 
.action-column')).toBeTruthy();
+        });
+
+        it('should place scope before actions when connected to a cluster', () 
=> {
+            store.setState({
+                [errorFeatureKey]: initialErrorState,
+                [currentUserFeatureKey]: initialCurrentUserState,
+                [clusterSummaryFeatureKey]: {
+                    ...clusterSummaryInitialState,
+                    clusterSummary: {
+                        clustered: true,
+                        connectedToCluster: true,
+                        connectedNodeCount: 2,
+                        totalNodeCount: 2
+                    }
+                },
+                [flowConfigurationFeatureKey]: flowConfigInitialState,
+                [componentStateFeatureKey]: mockInitialState
+            });
+            fixture.detectChanges();
+
+            expect(component.displayedColumns).toEqual(['key', 'value', 
'scope', 'actions']);
         });
 
         it('should initialize currentSortColumn and currentSortDirection', () 
=> {
@@ -309,6 +349,167 @@ describe('ComponentStateDialog', () => {
                 })
             );
         });
+
+        it('should not dispatch when the listing is partial', () => {
+            component.partialResults = true;
+            vi.mocked(store.dispatch).mockClear();
+
+            component.clearComponentStateEntry({
+                key: 'local-key1',
+                value: 'local-value1',
+                scope: 'node1:8443'
+            });
+
+            expect(store.dispatch).not.toHaveBeenCalled();
+        });
+    });
+
+    describe('partial listing', () => {
+        const truncatedLocalState: ComponentState = {
+            ...mockComponentState,
+            localState: {
+                scope: 'LOCAL',
+                state: [{ key: 'key1', value: 'value1' }],
+                totalEntryCount: 5
+            },
+            clusterState: undefined
+        };
+
+        const completeLocalState: ComponentState = {
+            ...mockComponentState,
+            localState: {
+                scope: 'LOCAL',
+                state: [{ key: 'key1', value: 'value1' }],
+                totalEntryCount: 1
+            },
+            clusterState: undefined
+        };
+
+        const truncatedClusterState: ComponentState = {
+            ...mockComponentState,
+            localState: {
+                scope: 'LOCAL',
+                state: [{ key: 'local-key1', value: 'local-value1', 
clusterNodeAddress: 'node1:8443' }],
+                totalEntryCount: 1
+            },
+            clusterState: {
+                scope: 'CLUSTER',
+                state: [{ key: 'cluster-key1', value: 'cluster-value1' }],
+                totalEntryCount: 5
+            }
+        };
+
+        function emitComponentState(componentState: ComponentState): void {
+            store.setState({
+                [errorFeatureKey]: initialErrorState,
+                [currentUserFeatureKey]: initialCurrentUserState,
+                [clusterSummaryFeatureKey]: clusterSummaryInitialState,
+                [flowConfigurationFeatureKey]: flowConfigInitialState,
+                [componentStateFeatureKey]: {
+                    ...mockInitialState,
+                    componentState
+                }
+            });
+            fixture.changeDetectorRef.markForCheck();
+        }
+
+        it('should hide the actions column and delete buttons when the listing 
is truncated', () => {
+            emitComponentState(truncatedLocalState);
+            fixture.detectChanges();
+
+            expect(component.partialResults).toBe(true);
+            expect(component.displayedColumns).not.toContain('actions');
+            expect(fixture.nativeElement.querySelector('thead 
.action-column')).toBeNull();
+            
expect(fixture.nativeElement.querySelector('[data-qa="component-state-clear-entry"]')).toBeNull();
+        });
+
+        it('should still dispatch clearComponentState when the listing is 
partial', () => {
+            emitComponentState(truncatedLocalState);
+            vi.mocked(store.dispatch).mockClear();
+
+            component.clearState();
+
+            
expect(store.dispatch).toHaveBeenCalledWith(ComponentStateActions.clearComponentState());
+        });
+
+        it('should show the partial-results banner when truncated', () => {
+            emitComponentState(truncatedLocalState);
+            fixture.detectChanges();
+
+            const banner = fixture.nativeElement.querySelector(
+                '[data-qa="component-state-partial-results-message"]'
+            ) as HTMLElement;
+            expect(banner).toBeTruthy();
+            expect(banner.textContent).toContain('Showing partial results');
+            expect(banner.textContent).toContain(
+                'Individual entries cannot be cleared while only partial 
results are shown'
+            );
+        });
+
+        it('should omit individual-clear copy on a partial listing when 
dropStateKeySupported is false', () => {
+            emitComponentState({
+                ...truncatedLocalState,
+                dropStateKeySupported: false
+            });
+            fixture.detectChanges();
+
+            const banner = fixture.nativeElement.querySelector(
+                '[data-qa="component-state-partial-results-message"]'
+            ) as HTMLElement;
+            expect(banner).toBeTruthy();
+            expect(banner.textContent).toContain('Showing partial results');
+            expect(banner.textContent).not.toContain('Individual entries 
cannot be cleared');
+        });
+
+        it('should not show the partial-results banner when the listing is 
complete', () => {
+            expect(
+                
fixture.nativeElement.querySelector('[data-qa="component-state-partial-results-message"]')
+            ).toBeNull();
+        });
+
+        it('should restore the actions column after a complete listing 
replaces a truncated one', async () => {
+            emitComponentState(truncatedLocalState);
+            fixture.detectChanges();
+            await fixture.whenStable();
+            expect(component.partialResults).toBe(true);
+            expect(component.displayedColumns).not.toContain('actions');
+            expect(fixture.nativeElement.querySelector('thead 
.action-column')).toBeNull();
+
+            emitComponentState(completeLocalState);
+            await fixture.whenStable();
+            fixture.detectChanges();
+            await renderVirtualRows();
+
+            expect(component.partialResults).toBe(false);
+            expect(component.displayedColumns).toContain('actions');
+            expect(fixture.nativeElement.querySelector('thead 
.action-column')).toBeTruthy();
+            
expect(fixture.nativeElement.querySelector('[data-qa="component-state-clear-entry"]')).toBeTruthy();
+        });
+
+        it('should hide the actions column when only the cluster map is 
truncated', () => {
+            store.setState({
+                [errorFeatureKey]: initialErrorState,
+                [currentUserFeatureKey]: initialCurrentUserState,
+                [clusterSummaryFeatureKey]: {
+                    ...clusterSummaryInitialState,
+                    clusterSummary: {
+                        clustered: true,
+                        connectedToCluster: true,
+                        connectedNodeCount: 2,
+                        totalNodeCount: 2
+                    }
+                },
+                [flowConfigurationFeatureKey]: flowConfigInitialState,
+                [componentStateFeatureKey]: {
+                    ...mockInitialState,
+                    componentState: truncatedClusterState
+                }
+            });
+            fixture.detectChanges();
+
+            expect(component.partialResults).toBe(true);
+            expect(component.displayedColumns).toEqual(['key', 'value', 
'scope']);
+        });
     });
 
     describe('processStateMap', () => {
diff --git 
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/component-state/component-state.component.ts
 
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/component-state/component-state.component.ts
index 3bc08330330..9d8d8576c74 100644
--- 
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/component-state/component-state.component.ts
+++ 
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/component-state/component-state.component.ts
@@ -15,7 +15,7 @@
  * limitations under the License.
  */
 
-import { AfterViewInit, Component, DestroyRef, inject, Input, Signal } from 
'@angular/core';
+import { AfterViewInit, Component, DestroyRef, effect, inject, Input, Signal } 
from '@angular/core';
 import { MatButtonModule } from '@angular/material/button';
 import { MatDialogModule } from '@angular/material/dialog';
 import { MatSortModule, Sort } from '@angular/material/sort';
@@ -48,7 +48,6 @@ import { MatInputModule } from '@angular/material/input';
 import { selectClusterSummary } from 
'../../../state/cluster-summary/cluster-summary.selectors';
 import { ErrorContextKey } from '../../../state/error';
 import { ContextErrorBanner } from 
'../context-error-banner/context-error-banner.component';
-import { concatLatestFrom } from '@ngrx/operators';
 import { MatTableModule } from '@angular/material/table';
 
 @Component({
@@ -80,8 +79,10 @@ export class ComponentStateDialog extends 
CloseOnEscapeDialog implements AfterVi
     @Input() initialSortDirection: 'asc' | 'desc' = 'asc';
 
     componentName$: Observable<string> = 
this.store.select(selectComponentName).pipe(isDefinedAndNotNull());
-    dropStateKeySupported$: Observable<boolean> = 
this.store.select(selectDropStateKeySupported);
+    dropStateKeySupported: Signal<boolean> = 
this.store.selectSignal(selectDropStateKeySupported);
+    canClear = this.store.selectSignal(selectCanClear);
     clearing: Signal<boolean> = this.store.selectSignal(selectClearing);
+    private readonly clusterSummary = 
this.store.selectSignal(selectClusterSummary);
 
     displayedColumns: string[] = ['key', 'value'];
     dataSource: StateItem[] = [];
@@ -100,7 +101,6 @@ export class ComponentStateDialog extends 
CloseOnEscapeDialog implements AfterVi
     totalEntries = 0;
     filteredEntries = 0;
     partialResults = false;
-    canClear = false;
     private destroyRef: DestroyRef = inject(DestroyRef);
 
     constructor() {
@@ -111,6 +111,7 @@ export class ComponentStateDialog extends 
CloseOnEscapeDialog implements AfterVi
             .select(selectComponentState)
             .pipe(isDefinedAndNotNull(), takeUntilDestroyed())
             .subscribe((componentState) => {
+                this.partialResults = false;
                 this.totalEntries = 0;
                 this.stateDescription = componentState.stateDescription;
 
@@ -142,48 +143,25 @@ export class ComponentStateDialog extends 
CloseOnEscapeDialog implements AfterVi
                 if (filterTerm?.length > 0) {
                     this.applyFilter(filterTerm);
                 }
-            });
 
-        this.store
-            .select(selectClusterSummary)
-            .pipe(isDefinedAndNotNull(), takeUntilDestroyed())
-            .subscribe((clusterSummary) => {
-                if (clusterSummary.connectedToCluster) {
-                    // if we're connected to the cluster add a scope column if 
it's not already present
-                    if (!this.displayedColumns.includes('scope')) {
-                        
this.displayedColumns.splice(this.displayedColumns.length, 0, 'scope');
-                    }
-                } else {
-                    // if we're not connected to the cluster remove the scope 
column if it is present
-                    const nodeIndex = this.displayedColumns.indexOf('scope');
-                    if (nodeIndex > -1) {
-                        this.displayedColumns.splice(nodeIndex, 1);
-                    }
-                }
+                this.syncDisplayedColumns();
             });
 
-        // Subscribe to dropStateKeySupported to conditionally show actions 
column
-        this.dropStateKeySupported$
-            .pipe(
-                concatLatestFrom(() => 
this.store.select(selectCanClear).pipe(isDefinedAndNotNull())),
-                takeUntilDestroyed()
-            )
-            .subscribe(([dropStateKeySupported, canClear]) => {
-                this.canClear = canClear;
-
-                if (canClear && dropStateKeySupported) {
-                    // Add actions column if it's not already present
-                    if (!this.displayedColumns.includes('actions')) {
-                        this.displayedColumns.push('actions');
-                    }
-                } else {
-                    // Remove actions column if it is present
-                    const actionsIndex = 
this.displayedColumns.indexOf('actions');
-                    if (actionsIndex > -1) {
-                        this.displayedColumns.splice(actionsIndex, 1);
-                    }
-                }
-            });
+        effect(() => this.syncDisplayedColumns());
+    }
+
+    private syncDisplayedColumns(): void {
+        const columns = ['key', 'value'];
+
+        if (this.clusterSummary()?.connectedToCluster) {
+            columns.push('scope');
+        }
+
+        if (this.canClear() && this.dropStateKeySupported() && 
!this.partialResults) {
+            columns.push('actions');
+        }
+
+        this.displayedColumns = columns;
     }
 
     ngAfterViewInit(): void {
@@ -290,6 +268,10 @@ export class ComponentStateDialog extends 
CloseOnEscapeDialog implements AfterVi
     }
 
     clearComponentStateEntry(item: StateItem): void {
+        if (this.partialResults) {
+            return;
+        }
+
         // Determine the scope based on the item's scope property
         // If scope is 'Cluster', it's cluster state
         // Otherwise, it's local state (could be node address or empty)

Reply via email to