This is an automated email from the ASF dual-hosted git repository.
rfellows 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 632b479dbf8 NIFI-16408 Cancel superseded registry resource requests in
flow registry dialogs (#11738)
632b479dbf8 is described below
commit 632b479dbf8f60dfd2b916a538e36b8410acad26
Author: boguszj <[email protected]>
AuthorDate: Wed Sep 30 18:54:46 2026 +0200
NIFI-16408 Cancel superseded registry resource requests in flow registry
dialogs (#11738)
This closes #11738
---
.../import-from-registry.component.spec.ts | 255 +++++++++++++++-
.../import-from-registry.component.ts | 340 ++++++++++++---------
.../save-version-dialog.component.spec.ts | 162 +++++++++-
.../save-version-dialog.component.ts | 150 +++++----
4 files changed, 694 insertions(+), 213 deletions(-)
diff --git
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/canvas/items/flow/import-from-registry/import-from-registry.component.spec.ts
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/canvas/items/flow/import-from-registry/import-from-registry.component.spec.ts
index bcc87085c8d..7d5ce5647de 100644
---
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/canvas/items/flow/import-from-registry/import-from-registry.component.spec.ts
+++
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/canvas/items/flow/import-from-registry/import-from-registry.component.spec.ts
@@ -18,12 +18,11 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { ImportFromRegistry } from './import-from-registry.component';
-import { ImportFromRegistryDialogRequest } from '../../../../../state/flow';
+import { flowFeatureKey, ImportFromRegistryDialogRequest } from
'../../../../../state/flow';
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
import { ComponentType } from '@nifi/shared';
import { provideMockStore } from '@ngrx/store/testing';
import { initialState as initialFlowState } from
'../../../../../state/flow/flow.reducer';
-import { flowFeatureKey } from '../../../../../state/flow';
import { canvasFeatureKey } from '../../../../../state';
import { initialState as initialErrorState } from
'../../../../../../../state/error/error.reducer';
import { errorFeatureKey } from '../../../../../../../state/error';
@@ -32,7 +31,15 @@ import { currentUserFeatureKey } from
'../../../../../../../state/current-user';
import { flowConfigurationFeatureKey } from
'../../../../../../../state/flow-configuration';
import { initialState as flowConfigurationInitialState } from
'../../../../../../../state/flow-configuration/flow-configuration.reducer';
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
-import { EMPTY } from 'rxjs';
+import { EMPTY, Observable, Subject } from 'rxjs';
+import {
+ BranchEntity,
+ BucketEntity,
+ RegistryClientEntity,
+ VersionedFlowEntity,
+ VersionedFlowSnapshotMetadataEntity
+} from '../../../../../../../state/shared';
+import { HttpErrorResponse } from '@angular/common/http';
import { ClusterConnectionService } from
'../../../../../../../service/cluster-connection.service';
import { By } from '@angular/platform-browser';
@@ -194,4 +201,246 @@ describe('ImportFromRegistry', () => {
expect(error).toBeFalsy();
expect(versions).toBeTruthy();
});
+
+ describe('with multiple registry clients', () => {
+ const registryAId = 'registry-a';
+ const registryBId = 'registry-b';
+ const bucketAId = 'bucket-a';
+ const bucketBId = 'bucket-b';
+
+ let branchRequests: Map<string, Subject<BranchEntity[]>>;
+ let bucketRequests: Map<string, Subject<BucketEntity[]>>;
+ let flowRequests: Map<string, Subject<VersionedFlowEntity[]>>;
+ let cancelledBucketRequests: string[];
+
+ const createRegistryClient = (id: string, name: string,
supportsBranching = false): RegistryClientEntity => {
+ const template = data.registryClients[0];
+ return {
+ ...template,
+ id,
+ component: {
+ ...template.component,
+ id,
+ name,
+ supportsBranching
+ }
+ };
+ };
+
+ const createBucket = (id: string): BucketEntity => ({
+ id,
+ permissions: { canRead: true, canWrite: true },
+ bucket: { id, created: 0, name: `bucket in ${id}`, description: ''
}
+ });
+
+ const createBranch = (registryId: string): BranchEntity =>
+ ({
+ branch: { name: `branch in ${registryId}` }
+ }) as BranchEntity;
+
+ const createFlow = (bucketId: string): VersionedFlowEntity =>
+ ({
+ versionedFlow: {
+ flowId: `flow in ${bucketId}`,
+ flowName: `flow in ${bucketId}`,
+ description: ''
+ }
+ }) as VersionedFlowEntity;
+
+ /**
+ * Registers a request per key so that the test controls when each one
responds, and records the
+ * keys whose requests were torn down before completing.
+ */
+ const controllableRequest = <T>(requests: Map<string, Subject<T>>,
cancelled?: string[]) => {
+ return (key: string) =>
+ new Observable<T>((subscriber) => {
+ const request = new Subject<T>();
+ requests.set(key, request);
+ const subscription = request.subscribe(subscriber);
+ return () => {
+ if (!request.closed) {
+ cancelled?.push(key);
+ }
+ subscription.unsubscribe();
+ };
+ });
+ };
+
+ const createDialog = (supportsBranching: boolean) => {
+ TestBed.resetTestingModule();
+ TestBed.configureTestingModule({
+ imports: [ImportFromRegistry, NoopAnimationsModule],
+ providers: [
+ {
+ provide: MAT_DIALOG_DATA,
+ useValue: {
+ ...data,
+ registryClients: [
+ createRegistryClient(registryAId, 'A
Registry', supportsBranching),
+ createRegistryClient(registryBId, 'B
Registry', supportsBranching)
+ ]
+ }
+ },
+ provideMockStore({
+ initialState: {
+ [errorFeatureKey]: initialErrorState,
+ [currentUserFeatureKey]: initialCurrentUserState,
+ [flowConfigurationFeatureKey]:
flowConfigurationInitialState,
+ [canvasFeatureKey]: {
+ [flowFeatureKey]: initialFlowState
+ }
+ }
+ }),
+ {
+ provide: ClusterConnectionService,
+ useValue: {
+ isDisconnectionAcknowledged: vi.fn()
+ }
+ },
+ { provide: MatDialogRef, useValue: null }
+ ]
+ });
+ fixture = TestBed.createComponent(ImportFromRegistry);
+ component = fixture.componentInstance;
+ component.getBranches = controllableRequest(branchRequests);
+ component.getBuckets = controllableRequest(bucketRequests,
cancelledBucketRequests);
+ component.getFlows = (registryId: string, bucketId: string) =>
controllableRequest(flowRequests)(bucketId);
+ component.getFlowVersions = () => EMPTY;
+
+ fixture.detectChanges();
+ };
+
+ beforeEach(() => {
+ branchRequests = new Map<string, Subject<BranchEntity[]>>();
+ bucketRequests = new Map<string, Subject<BucketEntity[]>>();
+ flowRequests = new Map<string, Subject<VersionedFlowEntity[]>>();
+ cancelledBucketRequests = [];
+ });
+
+ describe('that do not support branching', () => {
+ beforeEach(() => {
+ createDialog(false);
+ });
+
+ it('should ignore a bucket response for a registry that is no
longer selected', () => {
+ component.registryChanged(registryBId);
+
+
bucketRequests.get(registryAId)?.next([createBucket(registryAId)]);
+
+ expect(component.bucketOptions).toEqual([]);
+
expect(component.importFromRegistryForm.get('bucket')?.value).toBeNull();
+
+
bucketRequests.get(registryBId)?.next([createBucket(registryBId)]);
+
+ expect(component.bucketOptions.map((option) =>
option.value)).toEqual([registryBId]);
+
expect(component.importFromRegistryForm.get('bucket')?.value).toEqual(registryBId);
+ });
+
+ it('should cancel an outstanding bucket request when the selected
registry changes', () => {
+ expect(cancelledBucketRequests).toEqual([]);
+
+ component.registryChanged(registryBId);
+
+ expect(cancelledBucketRequests).toEqual([registryAId]);
+ });
+
+ it('should still load buckets for a later registry selection after
a failed bucket request', () => {
+ bucketRequests.get(registryAId)?.error(new Error('bucket
request failed'));
+
+ expect(component.loadingBuckets()).toBe(false);
+
+ component.registryChanged(registryBId);
+
bucketRequests.get(registryBId)?.next([createBucket(registryBId)]);
+
+ expect(component.bucketOptions.map((option) =>
option.value)).toEqual([registryBId]);
+
expect(component.importFromRegistryForm.get('bucket')?.value).toEqual(registryBId);
+ });
+
+ it('should still load flows for a later bucket selection after a
failed flow request', () => {
+ component.bucketChanged(bucketAId);
+ flowRequests.get(bucketAId)?.error(new Error('flow request
failed'));
+
+ expect(component.loadingFlows()).toBe(false);
+
+ component.bucketChanged(bucketBId);
+ flowRequests.get(bucketBId)?.next([createFlow(bucketBId)]);
+
+ expect(component.flowOptions.map((option) =>
option.value)).toEqual([`flow in ${bucketBId}`]);
+
expect(component.importFromRegistryForm.get('flow')?.value).toEqual(`flow in
${bucketBId}`);
+ });
+ });
+
+ describe('that support branching', () => {
+ beforeEach(() => {
+ createDialog(true);
+ });
+
+ it('should ignore a branch response for a registry that is no
longer selected', () => {
+ component.registryChanged(registryBId);
+
+
branchRequests.get(registryAId)?.next([createBranch(registryAId)]);
+
+ expect(component.branchOptions).toEqual([]);
+
expect(component.importFromRegistryForm.get('branch')?.value).toBeNull();
+ });
+
+ it('should still load branches for a later registry selection
after a failed branch request', () => {
+ branchRequests.get(registryAId)?.error(new Error('branch
request failed'));
+
+ expect(component.loadingBranches()).toBe(false);
+
+ component.registryChanged(registryBId);
+
branchRequests.get(registryBId)?.next([createBranch(registryBId)]);
+
+ expect(component.branchOptions.map((option) =>
option.value)).toEqual([`branch in ${registryBId}`]);
+
expect(component.importFromRegistryForm.get('branch')?.value).toEqual(`branch
in ${registryBId}`);
+ });
+
+ it('should re-enable the bucket control when its request is
cancelled without a replacement', () => {
+ // responding to the branch request auto-selects a branch,
which issues the bucket request
+
branchRequests.get(registryAId)?.next([createBranch(registryAId)]);
+
+ expect(component.loadingBuckets()).toBe(true);
+
expect(component.importFromRegistryForm.get('bucket')?.disabled).toBe(true);
+
+ // changing registry re-issues only the branch level, so
nothing replaces the cancelled
+ // bucket request; the bucket control must not be left disabled
+ component.registryChanged(registryBId);
+
+ expect(component.loadingBuckets()).toBe(false);
+
expect(component.importFromRegistryForm.get('bucket')?.disabled).toBe(false);
+ });
+ });
+ });
+
+ describe('when a versions request is superseded', () => {
+ let versionRequests: Subject<VersionedFlowSnapshotMetadataEntity[]>;
+
+ beforeEach(() => {
+ versionRequests = new
Subject<VersionedFlowSnapshotMetadataEntity[]>();
+ component.getFlowVersions = () => versionRequests;
+ });
+
+ it('should clear the versions loading state when the request is
cancelled without a replacement', () => {
+ component.flowChanged('flow-1');
+ expect(component.loadingVersions()).toBe(true);
+
+ // selecting another bucket clears the flow selection, so no
replacement versions request follows
+ component.bucketChanged('bucket-1');
+
+ expect(component.loadingVersions()).toBe(false);
+ });
+
+ it('should clear a previous versions error once the flow selection is
cleared', () => {
+ component.flowChanged('flow-1');
+ versionRequests.error(new HttpErrorResponse({ status: 409,
statusText: 'Conflict' }));
+
+ expect(component.loadingVersions()).toBe(false);
+ expect(component.loadingVersionsError()).not.toBeNull();
+
+ component.bucketChanged('bucket-1');
+
+ expect(component.loadingVersionsError()).toBeNull();
+ });
+ });
});
diff --git
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/canvas/items/flow/import-from-registry/import-from-registry.component.ts
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/canvas/items/flow/import-from-registry/import-from-registry.component.ts
index 0661e7cb21d..fac4b9a6a16 100644
---
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/canvas/items/flow/import-from-registry/import-from-registry.component.ts
+++
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/canvas/items/flow/import-from-registry/import-from-registry.component.ts
@@ -15,7 +15,7 @@
* limitations under the License.
*/
-import { Component, Input, OnInit, signal, WritableSignal, inject } from
'@angular/core';
+import { Component, inject, Input, OnInit, signal, WritableSignal } from
'@angular/core';
import { MAT_DIALOG_DATA, MatDialogModule } from '@angular/material/dialog';
import { ImportFromRegistryDialogRequest } from '../../../../../state/flow';
import { Store } from '@ngrx/store';
@@ -38,18 +38,18 @@ import { MatOptionModule } from '@angular/material/core';
import { MatSelectModule } from '@angular/material/select';
import { FormBuilder, FormControl, FormGroup, ReactiveFormsModule, Validators
} from '@angular/forms';
import { MatIconModule } from '@angular/material/icon';
-import { catchError, EMPTY, Observable, of, take } from 'rxjs';
+import { catchError, EMPTY, finalize, map, Observable, of, Subject, switchMap,
take } from 'rxjs';
import { MatCheckboxModule } from '@angular/material/checkbox';
import { MatTableDataSource, MatTableModule } from '@angular/material/table';
import { MatSortModule, Sort } from '@angular/material/sort';
import {
+ CloseOnEscapeDialog,
isDefinedAndNotNull,
- SelectOption,
NiFiCommon,
- TextTip,
+ NifiSpinnerDirective,
NifiTooltipDirective,
- CloseOnEscapeDialog,
- NifiSpinnerDirective
+ SelectOption,
+ TextTip
} from '@nifi/shared';
import { selectTimeOffset } from
'../../../../../../../state/flow-configuration/flow-configuration.selectors';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
@@ -62,6 +62,19 @@ import { ErrorHelper } from
'../../../../../../../service/error-helper.service';
import { HttpErrorResponse } from '@angular/common/http';
import { NgxSkeletonLoaderComponent } from 'ngx-skeleton-loader';
+interface LoadBucketsRequest {
+ registryId: string;
+ branch: string | null;
+}
+
+interface LoadFlowsRequest extends LoadBucketsRequest {
+ bucketId: string;
+}
+
+interface LoadVersionsRequest extends LoadFlowsRequest {
+ flowId: string;
+}
+
@Component({
selector: 'import-from-registry',
imports: [
@@ -139,6 +152,11 @@ export class ImportFromRegistry extends
CloseOnEscapeDialog implements OnInit {
loadingVersions: WritableSignal<boolean> = signal(false);
loadingVersionsError: WritableSignal<string | null> = signal(null);
+ private loadBranchesRequest = new Subject<string | null>();
+ private loadBucketsRequest = new Subject<LoadBucketsRequest | null>();
+ private loadFlowsRequest = new Subject<LoadFlowsRequest | null>();
+ private loadVersionsRequest = new Subject<LoadVersionsRequest | null>();
+
constructor() {
super();
const dialogRequest = this.dialogRequest;
@@ -176,46 +194,199 @@ export class ImportFromRegistry extends
CloseOnEscapeDialog implements OnInit {
flow: new FormControl(null, Validators.required),
keepParameterContexts: new FormControl(true, Validators.required)
});
+
+ this.loadBranchesRequest
+ .pipe(
+ switchMap((registryId: string | null) => {
+ if (!registryId) {
+ return EMPTY;
+ }
+
+ this.setLoading('branch', true);
+ this.branchOptions = [];
+
+ return this.getBranches(registryId).pipe(
+ take(1),
+ map((branches: BranchEntity[]) => ({ registryId,
branches })),
+ catchError(() => EMPTY),
+ finalize(() => this.setLoading('branch', false))
+ );
+ }),
+ takeUntilDestroyed()
+ )
+ .subscribe(({ registryId, branches }) => {
+ branches.forEach((entity: BranchEntity) => {
+ this.branchOptions.push({
+ text: entity.branch.name,
+ value: entity.branch.name
+ });
+ });
+
+ const branch = this.branchOptions[0]?.value;
+ if (branch) {
+
this.importFromRegistryForm.get('branch')?.setValue(branch);
+ this.loadBucketsRequest.next({ registryId, branch });
+ }
+ });
+
+ this.loadBucketsRequest
+ .pipe(
+ switchMap((request: LoadBucketsRequest | null) => {
+ if (!request) {
+ return EMPTY;
+ }
+
+ this.setLoading('bucket', true);
+ this.bucketOptions = [];
+
+ return this.getBuckets(request.registryId,
request.branch).pipe(
+ take(1),
+ map((buckets: BucketEntity[]) => ({ request, buckets
})),
+ catchError(() => EMPTY),
+ finalize(() => this.setLoading('bucket', false))
+ );
+ }),
+ takeUntilDestroyed()
+ )
+ .subscribe(({ request, buckets }) => {
+ buckets.forEach((entity: BucketEntity) => {
+ if (entity.permissions.canRead) {
+ this.bucketOptions.push({
+ text: entity.bucket.name,
+ value: entity.id,
+ description: entity.bucket.description
+ });
+ }
+ });
+
+ const bucketId = this.bucketOptions[0]?.value;
+ if (bucketId) {
+
this.importFromRegistryForm.get('bucket')?.setValue(bucketId);
+ this.loadFlowsRequest.next({ ...request, bucketId });
+ }
+ });
+
+ this.loadFlowsRequest
+ .pipe(
+ switchMap((request: LoadFlowsRequest | null) => {
+ if (!request) {
+ return EMPTY;
+ }
+
+ this.setLoading('flow', true);
+ this.flowOptions = [];
+ this.flowLookup.clear();
+
+ return this.getFlows(request.registryId, request.bucketId,
request.branch).pipe(
+ take(1),
+ map((versionedFlows: VersionedFlowEntity[]) => ({
request, versionedFlows })),
+ catchError(() => EMPTY),
+ finalize(() => this.setLoading('flow', false))
+ );
+ }),
+ takeUntilDestroyed()
+ )
+ .subscribe(({ request, versionedFlows }) => {
+ versionedFlows.forEach((entity: VersionedFlowEntity) => {
+ this.flowLookup.set(entity.versionedFlow.flowId!,
entity.versionedFlow);
+
+ this.flowOptions.push({
+ text: entity.versionedFlow.flowName,
+ value: entity.versionedFlow.flowId!,
+ description: entity.versionedFlow.description
+ });
+ });
+
+ const flowId = this.flowOptions[0]?.value;
+ if (flowId) {
+ this.importFromRegistryForm.get('flow')?.setValue(flowId);
+ this.loadVersionsRequest.next({ ...request, flowId });
+ }
+ });
+
+ this.loadVersionsRequest
+ .pipe(
+ switchMap((request: LoadVersionsRequest | null) => {
+ if (!request) {
+ this.loadingVersionsError.set(null);
+ return EMPTY;
+ }
+
+ this.setLoading('version', true);
+ this.loadingVersionsError.set(null);
+ this.dataSource.data = [];
+ this.selectedFlowVersion = null;
+ this.selectedFlowDescription =
this.flowLookup.get(request.flowId)?.description;
+
+ return this.getFlowVersions(
+ request.registryId,
+ request.bucketId,
+ request.flowId,
+ request.branch
+ ).pipe(
+ take(1),
+ catchError((errorResponse: HttpErrorResponse) => {
+
this.loadingVersionsError.set(this.errorHelper.getErrorString(errorResponse));
+ return EMPTY;
+ }),
+ finalize(() => this.setLoading('version', false))
+ );
+ }),
+ takeUntilDestroyed()
+ )
+ .subscribe((metadataEntities:
VersionedFlowSnapshotMetadataEntity[]) => {
+ if (metadataEntities.length > 0) {
+ const flowVersions = metadataEntities.map(
+ (entity: VersionedFlowSnapshotMetadataEntity) =>
entity.versionedFlowSnapshotMetadata
+ );
+
+ const sortedFlowVersions = this.sortVersions(flowVersions,
this.sort);
+ this.selectedFlowVersion = sortedFlowVersions[0].version;
+
+ this.dataSource.data = sortedFlowVersions;
+ }
+ });
}
ngOnInit(): void {
const selectedRegistryId =
this.importFromRegistryForm.get('registry')?.value;
if (selectedRegistryId) {
- this.supportsBranching =
this.clientBranchingSupportMap.get(selectedRegistryId) || false;
- if (this.supportsBranching) {
- this.loadBranches(selectedRegistryId);
- } else {
- this.loadBuckets(selectedRegistryId);
- }
+ this.registrySelected(selectedRegistryId);
}
}
registryChanged(registryId: string): void {
+ this.registrySelected(registryId);
+ }
+
+ private registrySelected(registryId: string): void {
this.supportsBranching =
this.clientBranchingSupportMap.get(registryId) || false;
this.clearBranches();
if (this.supportsBranching) {
- this.loadBranches(registryId);
+ this.loadBranchesRequest.next(registryId);
} else {
- this.loadBuckets(registryId);
+ this.loadBucketsRequest.next({ registryId, branch: null });
}
}
private clearBranches(): void {
this.branchOptions = [];
this.importFromRegistryForm.get('branch')?.setValue(null);
+ this.loadBranchesRequest.next(null);
this.clearBuckets();
}
branchChanged(branch: string): void {
this.clearBuckets();
const registryId = this.importFromRegistryForm.get('registry')?.value;
- this.loadBuckets(registryId, branch);
+ this.loadBucketsRequest.next({ registryId, branch });
}
private clearBuckets(): void {
this.bucketOptions = [];
this.importFromRegistryForm.get('bucket')?.setValue(null);
+ this.loadBucketsRequest.next(null);
this.clearFlows();
}
@@ -223,7 +394,7 @@ export class ImportFromRegistry extends CloseOnEscapeDialog
implements OnInit {
this.clearFlows();
const registryId = this.importFromRegistryForm.get('registry')?.value;
const branch = this.importFromRegistryForm.get('branch')?.value;
- this.loadFlows(registryId, bucketId, branch);
+ this.loadFlowsRequest.next({ registryId, branch, bucketId });
}
private clearFlows() {
@@ -231,13 +402,16 @@ export class ImportFromRegistry extends
CloseOnEscapeDialog implements OnInit {
this.flowOptions = [];
this.dataSource.data = [];
this.selectedFlowVersion = null;
+ this.loadingVersionsError.set(null);
+ this.loadFlowsRequest.next(null);
+ this.loadVersionsRequest.next(null);
}
flowChanged(flowId: string): void {
const registryId = this.importFromRegistryForm.get('registry')?.value;
const bucketId = this.importFromRegistryForm.get('bucket')?.value;
const branch = this.importFromRegistryForm.get('branch')?.value;
- this.loadVersions(registryId, bucketId, flowId, branch);
+ this.loadVersionsRequest.next({ registryId, branch, bucketId, flowId
});
}
private setLoading(resourceType: string, loading: boolean): void {
@@ -268,138 +442,6 @@ export class ImportFromRegistry extends
CloseOnEscapeDialog implements OnInit {
}
}
- loadBranches(registryId: string): void {
- if (registryId) {
- this.setLoading('branch', true);
- this.branchOptions = [];
-
- this.getBranches(registryId)
- .pipe(
- take(1),
- catchError(() => {
- this.setLoading('branch', false);
- return EMPTY;
- })
- )
- .subscribe((branches: BranchEntity[]) => {
- if (branches.length > 0) {
- branches.forEach((entity: BranchEntity) => {
- this.branchOptions.push({
- text: entity.branch.name,
- value: entity.branch.name
- });
- });
-
- const branchId = this.branchOptions[0].value;
- if (branchId) {
-
this.importFromRegistryForm.get('branch')?.setValue(branchId);
- this.loadBuckets(registryId, branchId);
- }
- }
- this.setLoading('branch', false);
- });
- }
- }
-
- loadBuckets(registryId: string, branch?: string | null): void {
- this.setLoading('bucket', true);
- this.bucketOptions = [];
-
- this.getBuckets(registryId, branch)
- .pipe(
- take(1),
- catchError(() => {
- this.setLoading('bucket', false);
- return EMPTY;
- })
- )
- .subscribe((buckets: BucketEntity[]) => {
- if (buckets.length > 0) {
- buckets.forEach((entity: BucketEntity) => {
- if (entity.permissions.canRead) {
- this.bucketOptions.push({
- text: entity.bucket.name,
- value: entity.id,
- description: entity.bucket.description
- });
- }
- });
-
- const bucketId = this.bucketOptions[0].value;
- if (bucketId) {
-
this.importFromRegistryForm.get('bucket')?.setValue(bucketId);
- this.loadFlows(registryId, bucketId, branch);
- }
- }
- this.setLoading('bucket', false);
- });
- }
-
- loadFlows(registryId: string, bucketId: string, branch?: string | null):
void {
- this.setLoading('flow', true);
- this.flowOptions = [];
- this.flowLookup.clear();
-
- this.getFlows(registryId, bucketId, branch)
- .pipe(
- take(1),
- catchError(() => {
- this.setLoading('flow', false);
- return EMPTY;
- })
- )
- .subscribe((versionedFlows: VersionedFlowEntity[]) => {
- if (versionedFlows.length > 0) {
- versionedFlows.forEach((entity: VersionedFlowEntity) => {
- this.flowLookup.set(entity.versionedFlow.flowId!,
entity.versionedFlow);
-
- this.flowOptions.push({
- text: entity.versionedFlow.flowName,
- value: entity.versionedFlow.flowId!,
- description: entity.versionedFlow.description
- });
- });
-
- const flowId = this.flowOptions[0].value;
- if (flowId) {
-
this.importFromRegistryForm.get('flow')?.setValue(flowId);
- this.loadVersions(registryId, bucketId, flowId,
branch);
- }
- }
- this.setLoading('flow', false);
- });
- }
-
- loadVersions(registryId: string, bucketId: string, flowId: string,
branch?: string | null): void {
- this.setLoading('version', true);
- this.dataSource.data = [];
- this.selectedFlowVersion = null;
- this.selectedFlowDescription =
this.flowLookup.get(flowId)?.description;
-
- this.getFlowVersions(registryId, bucketId, flowId, branch)
- .pipe(
- take(1),
- catchError((errorResponse: HttpErrorResponse) => {
- this.setLoading('version', false);
-
this.loadingVersionsError.set(this.errorHelper.getErrorString(errorResponse));
- return EMPTY;
- })
- )
- .subscribe((metadataEntities:
VersionedFlowSnapshotMetadataEntity[]) => {
- if (metadataEntities.length > 0) {
- const flowVersions = metadataEntities.map(
- (entity: VersionedFlowSnapshotMetadataEntity) =>
entity.versionedFlowSnapshotMetadata
- );
-
- const sortedFlowVersions = this.sortVersions(flowVersions,
this.sort);
- this.selectedFlowVersion = sortedFlowVersions[0].version;
-
- this.dataSource.data = sortedFlowVersions;
- }
- this.setLoading('version', false);
- });
- }
-
formatTimestamp(flowVersion: VersionedFlowSnapshotMetadata) {
// get the current user time to properly convert the server time
const now: Date = new Date();
diff --git
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/canvas/items/flow/save-version-dialog/save-version-dialog.component.spec.ts
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/canvas/items/flow/save-version-dialog/save-version-dialog.component.spec.ts
index 772b2b19ea9..f124c4ca658 100644
---
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/canvas/items/flow/save-version-dialog/save-version-dialog.component.spec.ts
+++
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/canvas/items/flow/save-version-dialog/save-version-dialog.component.spec.ts
@@ -28,7 +28,8 @@ import { initialState as initialErrorState } from
'../../../../../../../state/er
import { errorFeatureKey } from '../../../../../../../state/error';
import { initialState as initialCurrentUserState } from
'../../../../../../../state/current-user/current-user.reducer';
import { currentUserFeatureKey } from
'../../../../../../../state/current-user';
-import { EMPTY } from 'rxjs';
+import { EMPTY, Observable, Subject } from 'rxjs';
+import { BranchEntity, BucketEntity, RegistryClientEntity } from
'../../../../../../../state/shared';
import { Signal } from '@angular/core';
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
@@ -150,4 +151,163 @@ describe('SaveVersionDialog', () => {
it('should create', () => {
expect(component).toBeTruthy();
});
+
+ describe('with multiple registry clients', () => {
+ const registryAId = 'registry-a';
+ const registryBId = 'registry-b';
+
+ let bucketRequests: Map<string, Subject<BucketEntity[]>>;
+ let branchRequests: Map<string, Subject<BranchEntity[]>>;
+ let cancelledBucketRequests: string[];
+
+ const createRegistryClient = (id: string, name: string,
supportsBranching = false): RegistryClientEntity => {
+ const template = data.registryClients![0];
+ return {
+ ...template,
+ id,
+ component: {
+ ...template.component,
+ id,
+ name,
+ supportsBranching
+ }
+ };
+ };
+
+ const createBucket = (id: string): BucketEntity => ({
+ id,
+ permissions: { canRead: true, canWrite: true },
+ bucket: { id, created: 0, name: `bucket in ${id}`, description: ''
}
+ });
+
+ const createBranch = (registryId: string): BranchEntity =>
+ ({
+ branch: { name: `branch in ${registryId}` }
+ }) as BranchEntity;
+
+ /**
+ * Registers a request per registry id so that the test controls when
each one responds, and
+ * records the registry ids whose requests were torn down before
completing.
+ */
+ const controllableRequest = <T>(requests: Map<string, Subject<T>>,
cancelled?: string[]) => {
+ return (registryId: string) =>
+ new Observable<T>((subscriber) => {
+ const request = new Subject<T>();
+ requests.set(registryId, request);
+ const subscription = request.subscribe(subscriber);
+ return () => {
+ if (!request.closed) {
+ cancelled?.push(registryId);
+ }
+ subscription.unsubscribe();
+ };
+ });
+ };
+
+ const createDialog = async (supportsBranching: boolean) => {
+ TestBed.resetTestingModule();
+ await TestBed.configureTestingModule({
+ imports: [SaveVersionDialog, MatDialogModule,
NoopAnimationsModule],
+ providers: [
+ {
+ provide: MAT_DIALOG_DATA,
+ useValue: {
+ ...data,
+ registryClients: [
+ createRegistryClient(registryAId, 'A
Registry', supportsBranching),
+ createRegistryClient(registryBId, 'B
Registry', supportsBranching)
+ ]
+ }
+ },
+ provideMockStore({
+ initialState: {
+ [errorFeatureKey]: initialErrorState,
+ [currentUserFeatureKey]: initialCurrentUserState,
+ [canvasFeatureKey]: {
+ [flowFeatureKey]: initialFlowState
+ }
+ }
+ }),
+ { provide: MatDialogRef, useValue: null }
+ ]
+ }).compileComponents();
+
+ fixture = TestBed.createComponent(SaveVersionDialog);
+ component = fixture.componentInstance;
+ component.getBranches = controllableRequest(branchRequests);
+ component.getBuckets = controllableRequest(bucketRequests,
cancelledBucketRequests);
+ component.saving = (() => false) as Signal<boolean>;
+
+ fixture.detectChanges();
+ };
+
+ beforeEach(() => {
+ bucketRequests = new Map<string, Subject<BucketEntity[]>>();
+ branchRequests = new Map<string, Subject<BranchEntity[]>>();
+ cancelledBucketRequests = [];
+ });
+
+ describe('that do not support branching', () => {
+ beforeEach(async () => {
+ await createDialog(false);
+ });
+
+ it('should ignore a bucket response for a registry that is no
longer selected', () => {
+ component.registryChanged(registryBId);
+
+
bucketRequests.get(registryAId)?.next([createBucket(registryAId)]);
+
+ expect(component.bucketOptions).toEqual([]);
+
expect(component.saveVersionForm.get('bucket')?.value).toBeNull();
+
+
bucketRequests.get(registryBId)?.next([createBucket(registryBId)]);
+
+ expect(component.bucketOptions.map((option) =>
option.value)).toEqual([registryBId]);
+
expect(component.saveVersionForm.get('bucket')?.value).toEqual(registryBId);
+ });
+
+ it('should cancel an outstanding bucket request when the selected
registry changes', () => {
+ expect(cancelledBucketRequests).toEqual([]);
+
+ component.registryChanged(registryBId);
+
+ expect(cancelledBucketRequests).toEqual([registryAId]);
+ });
+
+ it('should still load buckets for a later registry selection after
a failed bucket request', () => {
+ bucketRequests.get(registryAId)?.error(new Error('bucket
request failed'));
+
+ component.registryChanged(registryBId);
+
bucketRequests.get(registryBId)?.next([createBucket(registryBId)]);
+
+ expect(component.bucketOptions.map((option) =>
option.value)).toEqual([registryBId]);
+
expect(component.saveVersionForm.get('bucket')?.value).toEqual(registryBId);
+ });
+ });
+
+ describe('that support branching', () => {
+ beforeEach(async () => {
+ await createDialog(true);
+ });
+
+ it('should ignore a branch response for a registry that is no
longer selected', () => {
+ component.registryChanged(registryBId);
+
+
branchRequests.get(registryAId)?.next([createBranch(registryAId)]);
+
+ expect(component.branchOptions).toEqual([]);
+
expect(component.saveVersionForm.get('branch')?.value).toBeNull();
+ });
+
+ it('should still load branches for a later registry selection
after a failed branch request', () => {
+ branchRequests.get(registryAId)?.error(new Error('branch
request failed'));
+
+ component.registryChanged(registryBId);
+
branchRequests.get(registryBId)?.next([createBranch(registryBId)]);
+
+ expect(component.branchOptions.map((option) =>
option.value)).toEqual([`branch in ${registryBId}`]);
+
expect(component.saveVersionForm.get('branch')?.value).toEqual(`branch in
${registryBId}`);
+ });
+ });
+ });
});
diff --git
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/canvas/items/flow/save-version-dialog/save-version-dialog.component.ts
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/canvas/items/flow/save-version-dialog/save-version-dialog.component.ts
index 4877ccebe6d..46056ec38d7 100644
---
a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/canvas/items/flow/save-version-dialog/save-version-dialog.component.ts
+++
b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/canvas/items/flow/save-version-dialog/save-version-dialog.component.ts
@@ -15,7 +15,7 @@
* limitations under the License.
*/
-import { Component, EventEmitter, Input, OnInit, Output, Signal, inject } from
'@angular/core';
+import { Component, DestroyRef, EventEmitter, inject, Input, OnInit, Output,
Signal } from '@angular/core';
import {
MAT_DIALOG_DATA,
MatDialogActions,
@@ -27,22 +27,28 @@ import { FormBuilder, FormControl, FormGroup,
ReactiveFormsModule, Validators }
import { MatButton } from '@angular/material/button';
import { MatError, MatFormField, MatLabel } from
'@angular/material/form-field';
import { MatOption, MatSelect } from '@angular/material/select';
-import { Observable, of, take } from 'rxjs';
+import { catchError, EMPTY, map, Observable, of, Subject, switchMap, take }
from 'rxjs';
import { BranchEntity, BucketEntity, RegistryClientEntity } from
'../../../../../../../state/shared';
import { SaveVersionDialogRequest, SaveVersionRequest } from
'../../../../../state/flow';
import { VersionControlInformation } from
'../../../../../../../ui/common/tooltips/version-control-tip/version-control-tip.component';
import {
- TextTip,
+ CloseOnEscapeDialog,
NiFiCommon,
NifiSpinnerDirective,
NifiTooltipDirective,
- CloseOnEscapeDialog,
- SelectOption
+ SelectOption,
+ TextTip
} from '@nifi/shared';
import { MatInput } from '@angular/material/input';
import { ErrorContextKey } from '../../../../../../../state/error';
import { ContextErrorBanner } from
'../../../../../../../ui/common/context-error-banner/context-error-banner.component';
+import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
+
+interface LoadBucketsRequest {
+ registryId: string;
+ branch: string | null;
+}
@Component({
selector: 'save-version-dialog',
@@ -70,6 +76,7 @@ export class SaveVersionDialog extends CloseOnEscapeDialog
implements OnInit {
private dialogRequest = inject<SaveVersionDialogRequest>(MAT_DIALOG_DATA);
private formBuilder = inject(FormBuilder);
private nifiCommon = inject(NiFiCommon);
+ private destroyRef = inject(DestroyRef);
@Input() getBranches: (registryId: string) => Observable<BranchEntity[]> =
() => of([]);
@Input() getBuckets: (registryId: string, branch?: string | null) =>
Observable<BucketEntity[]> = () => of([]);
@@ -87,6 +94,9 @@ export class SaveVersionDialog extends CloseOnEscapeDialog
implements OnInit {
private clientBranchingSupportMap: Map<string, boolean> = new Map<string,
boolean>();
+ private loadBranchesRequest = new Subject<string | null>();
+ private loadBucketsRequest = new Subject<LoadBucketsRequest | null>();
+
constructor() {
super();
const dialogRequest = this.dialogRequest;
@@ -123,6 +133,8 @@ export class SaveVersionDialog extends CloseOnEscapeDialog
implements OnInit {
flowDescription: new FormControl(null),
comments: new FormControl(null)
});
+
+ this.wireRegistryResourceRequests();
} else {
this.saveVersionForm = formBuilder.group({
comments: new FormControl('')
@@ -135,89 +147,107 @@ export class SaveVersionDialog extends
CloseOnEscapeDialog implements OnInit {
const selectedRegistryId: string | null =
this.saveVersionForm.get('registry')?.value;
if (selectedRegistryId) {
- this.supportsBranching =
this.clientBranchingSupportMap.get(selectedRegistryId) || false;
- if (this.supportsBranching) {
- this.loadBranches(selectedRegistryId);
- } else {
- this.loadBuckets(selectedRegistryId);
- }
+ this.registrySelected(selectedRegistryId);
}
}
}
- private clearBranches(): void {
- this.branchOptions = [];
- this.saveVersionForm.get('branch')?.setValue(null);
- this.clearBuckets();
- }
+ private wireRegistryResourceRequests(): void {
+ this.loadBranchesRequest
+ .pipe(
+ switchMap((registryId: string | null) => {
+ if (!registryId) {
+ return EMPTY;
+ }
+ this.branchOptions = [];
+ return this.getBranches(registryId).pipe(
+ take(1),
+ map((branches: BranchEntity[]) => ({ registryId,
branches })),
+ catchError(() => EMPTY)
+ );
+ }),
+ takeUntilDestroyed(this.destroyRef)
+ )
+ .subscribe(({ registryId, branches }) => {
+ branches.forEach((entity: BranchEntity) => {
+ this.branchOptions.push({
+ text: entity.branch.name,
+ value: entity.branch.name
+ });
+ });
- loadBranches(registryId: string): void {
- this.clearBranches();
+ const branch = this.branchOptions[0]?.value;
+ if (branch) {
+ this.saveVersionForm.get('branch')?.setValue(branch);
+ this.loadBucketsRequest.next({ registryId, branch });
+ }
+ });
- this.getBranches(registryId)
- .pipe(take(1))
- .subscribe((branches: BranchEntity[]) => {
- if (branches.length > 0) {
- branches.forEach((entity: BranchEntity) => {
- this.branchOptions.push({
- text: entity.branch.name,
- value: entity.branch.name
+ this.loadBucketsRequest
+ .pipe(
+ switchMap((request: LoadBucketsRequest | null) => {
+ if (!request) {
+ return EMPTY;
+ }
+ this.bucketOptions = [];
+ return this.getBuckets(request.registryId,
request.branch).pipe(
+ take(1),
+ catchError(() => EMPTY)
+ );
+ }),
+ takeUntilDestroyed(this.destroyRef)
+ )
+ .subscribe((buckets: BucketEntity[]) => {
+ buckets.forEach((entity: BucketEntity) => {
+ // only allow buckets to be selectable if the user can
read and write to them
+ if (entity.permissions.canRead &&
entity.permissions.canWrite) {
+ this.bucketOptions.push({
+ text: entity.bucket.name,
+ value: entity.id,
+ description: entity.bucket.description
});
- });
-
- const branchId = this.branchOptions[0].value;
- if (branchId) {
- this.saveVersionForm.get('branch')?.setValue(branchId);
- this.loadBuckets(registryId, branchId);
}
+ });
+
+ const bucketId = this.bucketOptions[0]?.value;
+ if (bucketId) {
+ this.saveVersionForm.get('bucket')?.setValue(bucketId);
}
});
}
+ private clearBranches(): void {
+ this.branchOptions = [];
+ this.saveVersionForm.get('branch')?.setValue(null);
+ this.loadBranchesRequest.next(null);
+ this.clearBuckets();
+ }
+
private clearBuckets(): void {
this.bucketOptions = [];
this.saveVersionForm.get('bucket')?.setValue(null);
+ this.loadBucketsRequest.next(null);
}
- loadBuckets(registryId: string, branch?: string | null): void {
- this.clearBuckets();
-
- this.getBuckets(registryId, branch)
- .pipe(take(1))
- .subscribe((buckets: BucketEntity[]) => {
- if (buckets.length > 0) {
- buckets.forEach((entity: BucketEntity) => {
- // only allow buckets to be selectable if the user can
read and write to them
- if (entity.permissions.canRead &&
entity.permissions.canWrite) {
- this.bucketOptions.push({
- text: entity.bucket.name,
- value: entity.id,
- description: entity.bucket.description
- });
- }
- });
-
- const bucketId = this.bucketOptions[0].value;
- if (bucketId) {
- this.saveVersionForm.get('bucket')?.setValue(bucketId);
- }
- }
- });
+ registryChanged(registryId: string): void {
+ this.registrySelected(registryId);
}
- registryChanged(registryId: string): void {
+ private registrySelected(registryId: string): void {
this.supportsBranching =
this.clientBranchingSupportMap.get(registryId) || false;
+ this.clearBranches();
if (this.supportsBranching) {
- this.loadBranches(registryId);
+ this.loadBranchesRequest.next(registryId);
} else {
- this.loadBuckets(registryId);
+ this.loadBucketsRequest.next({ registryId, branch: null });
}
}
branchChanged(branch: string): void {
const selectedRegistryId: string | null =
this.saveVersionForm.get('registry')?.value;
if (selectedRegistryId) {
- this.loadBuckets(selectedRegistryId, branch);
+ this.clearBuckets();
+ this.loadBucketsRequest.next({ registryId: selectedRegistryId,
branch });
}
}