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)