diff --git a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/service/registry.service.ts b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/service/registry.service.ts index d27bd40861cb..f216e16b9d93 100644 --- a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/service/registry.service.ts +++ b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/service/registry.service.ts @@ -18,7 +18,7 @@ import { Injectable, inject } from '@angular/core'; import { Observable } from 'rxjs'; import { HttpClient, HttpParams } from '@angular/common/http'; -import { ImportFromRegistryRequest } from '../state/flow'; +import { FlowComparisonEntity, ImportFromRegistryRequest } from '../state/flow'; @Injectable({ providedIn: 'root' }) export class RegistryService { @@ -63,6 +63,31 @@ export class RegistryService { ); } + getFlowDiff( + registryId: string, + bucketId: string, + flowId: string, + versionA: string, + versionB: string, + branch?: string | null + ): Observable { + const encodedRegistryId = encodeURIComponent(registryId); + const encodedBucketId = encodeURIComponent(bucketId); + const encodedFlowId = encodeURIComponent(flowId); + const encodedVersionA = encodeURIComponent(versionA); + const encodedVersionB = encodeURIComponent(versionB); + + if (branch) { + const encodedBranch = encodeURIComponent(branch); + const diffEndpoint = `${RegistryService.API}/flow/registries/${encodedRegistryId}/branches/${encodedBranch}/buckets/${encodedBucketId}/flows/${encodedFlowId}/${encodedVersionA}/diff/branches/${encodedBranch}/buckets/${encodedBucketId}/flows/${encodedFlowId}/${encodedVersionB}`; + return this.httpClient.get(diffEndpoint) as Observable; + } + + let params: HttpParams = new HttpParams(); + const legacyEndpoint = `${RegistryService.API}/flow/registries/${encodedRegistryId}/buckets/${encodedBucketId}/flows/${encodedFlowId}/diff/${encodedVersionA}/${encodedVersionB}`; + return this.httpClient.get(legacyEndpoint, { params }) as Observable; + } + importFromRegistry(processGroupId: string, request: ImportFromRegistryRequest): Observable { return this.httpClient.post( `${RegistryService.API}/process-groups/${processGroupId}/process-groups`, diff --git a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/canvas/items/flow/change-version-dialog/change-version-dialog.html b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/canvas/items/flow/change-version-dialog/change-version-dialog.html index 6d3d8e27f2c0..fa2ee682f376 100644 --- a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/canvas/items/flow/change-version-dialog/change-version-dialog.html +++ b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/canvas/items/flow/change-version-dialog/change-version-dialog.html @@ -123,6 +123,24 @@

+ + + + + + + + { async function setup(options: SetupOptions = {}) { const dialogData = options.dialogData || createMockDialogData(); const timeOffset = options.timeOffset || 0; + const matDialogOpen = jest.fn(); await TestBed.configureTestingModule({ imports: [ChangeVersionDialog, NoopAnimationsModule], @@ -112,7 +114,13 @@ describe('ChangeVersionDialog', () => { } ] }), - { provide: MatDialogRef, useValue: null } + { provide: MatDialogRef, useValue: null }, + { + provide: MatDialog, + useValue: { + open: matDialogOpen + } + } ] }).compileComponents(); @@ -120,7 +128,7 @@ describe('ChangeVersionDialog', () => { const component = fixture.componentInstance; fixture.detectChanges(); - return { fixture, component, dialogData }; + return { fixture, component, dialogData, matDialogOpen }; } beforeEach(() => { @@ -133,6 +141,12 @@ describe('ChangeVersionDialog', () => { expect(component).toBeTruthy(); }); + it('should include flow diff action column', async () => { + const { component } = await setup(); + + expect(component.displayedColumns).toContain('actions'); + }); + describe('Component Initialization', () => { it('should initialize with sorted flow versions', async () => { const { component } = await setup(); @@ -214,6 +228,34 @@ describe('ChangeVersionDialog', () => { }); }); + describe('Flow Diff', () => { + it('should open the flow diff dialog when the action is triggered', async () => { + const { fixture, matDialogOpen, dialogData } = await setup(); + const diffButton = fixture.debugElement.query(By.css('[data-qa="view-flow-diff-button"]')); + const mockEvent = { + preventDefault: jest.fn(), + stopPropagation: jest.fn() + } as unknown as MouseEvent; + + diffButton.triggerEventHandler('click', mockEvent); + + expect(mockEvent.preventDefault).toHaveBeenCalled(); + expect(mockEvent.stopPropagation).toHaveBeenCalled(); + const icon = diffButton.query(By.css('i')); + expect(icon.nativeElement.classList).toContain('fa-exchange'); + expect(matDialogOpen).toHaveBeenCalledWith( + FlowDiffDialog, + expect.objectContaining({ + data: expect.objectContaining({ + versionControlInformation: dialogData.versionControlInformation, + currentVersion: dialogData.versionControlInformation.version, + selectedVersion: dialogData.versions[0].versionedFlowSnapshotMetadata.version + }) + }) + ); + }); + }); + describe('Selection Validation', () => { it('should be invalid when no version is selected', async () => { const { component } = await setup(); diff --git a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/canvas/items/flow/change-version-dialog/change-version-dialog.ts b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/canvas/items/flow/change-version-dialog/change-version-dialog.ts index 3585a37267ef..253b26127b2f 100644 --- a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/canvas/items/flow/change-version-dialog/change-version-dialog.ts +++ b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/canvas/items/flow/change-version-dialog/change-version-dialog.ts @@ -16,16 +16,19 @@ */ import { Component, EventEmitter, Output, inject } from '@angular/core'; -import { MatButton } from '@angular/material/button'; +import { MatButton, MatIconButton } from '@angular/material/button'; import { MatCell, MatCellDef, MatColumnDef, MatTableDataSource, MatTableModule } from '@angular/material/table'; -import { MAT_DIALOG_DATA, MatDialogModule } from '@angular/material/dialog'; +import { MAT_DIALOG_DATA, MatDialog, MatDialogModule } from '@angular/material/dialog'; import { MatSortModule, Sort } from '@angular/material/sort'; import { VersionedFlowSnapshotMetadata } from '../../../../../../../state/shared'; import { ChangeVersionDialogRequest, VersionControlInformation } from '../../../../../state/flow'; import { Store } from '@ngrx/store'; import { CanvasState } from '../../../../../state'; import { selectTimeOffset } from '../../../../../../../state/flow-configuration/flow-configuration.selectors'; -import { NiFiCommon, CloseOnEscapeDialog, NifiTooltipDirective, TextTip } from '@nifi/shared'; +import { NiFiCommon, CloseOnEscapeDialog, NifiTooltipDirective, TextTip, LARGE_DIALOG } from '@nifi/shared'; +import { FlowDiffDialog, FlowDiffDialogData } from '../flow-diff-dialog/flow-diff-dialog'; +import { ErrorContextKey } from '../../../../../../../state/error'; +import * as ErrorActions from '../../../../../../../state/error/error.actions'; @Component({ selector: 'change-version-dialog', @@ -37,7 +40,8 @@ import { NiFiCommon, CloseOnEscapeDialog, NifiTooltipDirective, TextTip } from ' MatDialogModule, MatSortModule, MatTableModule, - NifiTooltipDirective + NifiTooltipDirective, + MatIconButton ], templateUrl: './change-version-dialog.html', styleUrl: './change-version-dialog.scss' @@ -47,16 +51,18 @@ export class ChangeVersionDialog extends CloseOnEscapeDialog { private nifiCommon = inject(NiFiCommon); private store = inject>(Store); - displayedColumns: string[] = ['current', 'version', 'created', 'comments']; + displayedColumns: string[] = ['current', 'version', 'created', 'comments', 'actions']; dataSource: MatTableDataSource = new MatTableDataSource(); selectedFlowVersion: VersionedFlowSnapshotMetadata | null = null; + private allFlowVersions: VersionedFlowSnapshotMetadata[] = []; sort: Sort = { active: 'created', direction: 'desc' }; versionControlInformation: VersionControlInformation; private timeOffset = this.store.selectSignal(selectTimeOffset); + private dialog = inject(MatDialog); @Output() changeVersion: EventEmitter = new EventEmitter(); @@ -66,6 +72,7 @@ export class ChangeVersionDialog extends CloseOnEscapeDialog { const dialogRequest = this.dialogRequest; const flowVersions = dialogRequest.versions.map((entity) => entity.versionedFlowSnapshotMetadata); + this.allFlowVersions = flowVersions; const sortedFlowVersions = this.sortVersions(flowVersions, this.sort); this.selectedFlowVersion = sortedFlowVersions[0]; this.dataSource.data = sortedFlowVersions; @@ -151,5 +158,41 @@ export class ChangeVersionDialog extends CloseOnEscapeDialog { return flowVersion.version === this.versionControlInformation.version; } + openFlowDiff(flowVersion: VersionedFlowSnapshotMetadata, event: MouseEvent) { + event.preventDefault(); + event.stopPropagation(); + + if (!this.versionControlInformation) { + return; + } + + const errorContext = ErrorContextKey.FLOW_VERSION; + + const dialogData: FlowDiffDialogData = { + versionControlInformation: this.versionControlInformation, + versions: this.allFlowVersions, + currentVersion: this.versionControlInformation.version, + selectedVersion: flowVersion.version, + errorContext, + clearBannerErrors: () => this.store.dispatch(ErrorActions.clearBannerErrors({ context: errorContext })), + addBannerError: (errors: string[]) => + this.store.dispatch( + ErrorActions.addBannerError({ + errorContext: { + context: errorContext, + errors + } + }) + ), + formatTimestamp: (metadata) => this.formatTimestamp(metadata) + }; + + this.dialog.open(FlowDiffDialog, { + ...LARGE_DIALOG, + data: dialogData, + autoFocus: false + }); + } + protected readonly TextTip = TextTip; } diff --git a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/canvas/items/flow/flow-diff-dialog/flow-diff-dialog.html b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/canvas/items/flow/flow-diff-dialog/flow-diff-dialog.html new file mode 100644 index 000000000000..c864d3562e1d --- /dev/null +++ b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/canvas/items/flow/flow-diff-dialog/flow-diff-dialog.html @@ -0,0 +1,128 @@ + + +

+ Flow Version Diff - {{ flowName }} +

+
+ + +
+
+
Comparing versions
+
+
+
{{ summary.label }}
+
+ {{ summary.version }} + + ({{ summary.created }}) + +
+
+
+
+ +
+
+ + Current Version + + + {{ formatVersionOption(version) }} + + + +
+ +
+ + Selected Version + + + {{ formatVersionOption(version) }} + + + +
+ +
+ + Filter + + +
+
+ +
+
+ +
+ No differences to display. +
+ + + + + + + + + + + + + + + + + + + +
Component Name + {{ row.componentName || 'Unknown' }} + Change Type + {{ row.changeType }} + Difference + {{ row.difference }} +
+
+
+
+
+
+ + + +
diff --git a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/canvas/items/flow/flow-diff-dialog/flow-diff-dialog.scss b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/canvas/items/flow/flow-diff-dialog/flow-diff-dialog.scss new file mode 100644 index 000000000000..d247f497be95 --- /dev/null +++ b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/canvas/items/flow/flow-diff-dialog/flow-diff-dialog.scss @@ -0,0 +1,22 @@ +/*! + * 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. + */ + +@use '@angular/material' as mat; + +.flow-diff { + @include mat.button-density(-1); +} diff --git a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/canvas/items/flow/flow-diff-dialog/flow-diff-dialog.spec.ts b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/canvas/items/flow/flow-diff-dialog/flow-diff-dialog.spec.ts new file mode 100644 index 000000000000..e6c1232fa086 --- /dev/null +++ b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/canvas/items/flow/flow-diff-dialog/flow-diff-dialog.spec.ts @@ -0,0 +1,185 @@ +/* + * 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 { TestBed, fakeAsync, tick } from '@angular/core/testing'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { MAT_DIALOG_DATA } from '@angular/material/dialog'; +import { of, throwError } from 'rxjs'; +import { FlowDiffDialog, FlowDiffDialogData } from './flow-diff-dialog'; +import { RegistryService } from '../../../../../service/registry.service'; +import { FlowComparisonEntity } from '../../../../../state/flow'; +import { VersionedFlowSnapshotMetadata } from '../../../../../../../state/shared'; +import { By } from '@angular/platform-browser'; +import { ErrorContextKey } from '../../../../../../../state/error'; + +describe('FlowDiffDialog', () => { + const versions: VersionedFlowSnapshotMetadata[] = [ + { + bucketIdentifier: 'bucket', + flowIdentifier: 'flow', + version: '2', + timestamp: 1712171233843, + author: 'nifi', + comments: 'Second version' + }, + { + bucketIdentifier: 'bucket', + flowIdentifier: 'flow', + version: '1', + timestamp: 1712076498414, + author: 'nifi', + comments: 'Initial version' + } + ]; + + const baseDialogData: FlowDiffDialogData = { + versionControlInformation: { + groupId: 'group-id', + registryId: 'registry-id', + registryName: 'registry', + bucketId: 'bucket', + bucketName: 'bucket', + flowId: 'flow', + flowName: 'Sample Flow', + flowDescription: '', + version: '2', + state: 'UP_TO_DATE', + stateExplanation: '', + branch: null + }, + versions, + currentVersion: '2', + selectedVersion: '1', + errorContext: ErrorContextKey.FLOW_VERSION, + clearBannerErrors: () => {}, + addBannerError: () => {}, + formatTimestamp: (metadata: VersionedFlowSnapshotMetadata) => + `Formatted ${metadata.version}` + }; + + const comparison: FlowComparisonEntity = { + componentDifferences: [ + { + componentType: 'Processor', + componentId: 'processor', + processGroupId: 'group-id', + componentName: 'GenerateFlowFile', + differences: [ + { + differenceType: 'Property Modified', + difference: 'Scheduling period changed' + } + ] + } + ] + }; + + let getFlowDiffSpy: jest.Mock; + let clearBannerErrorsMock: jest.Mock; + let addBannerErrorMock: jest.Mock; + + function configureTestingModule(dialogData: FlowDiffDialogData = baseDialogData) { + getFlowDiffSpy = jest.fn().mockReturnValue(of(comparison)); + clearBannerErrorsMock = jest.fn(); + addBannerErrorMock = jest.fn(); + + TestBed.configureTestingModule({ + imports: [FlowDiffDialog, NoopAnimationsModule], + providers: [ + { + provide: RegistryService, + useValue: { + getFlowDiff: getFlowDiffSpy + } + }, + { + provide: MAT_DIALOG_DATA, + useValue: { + ...dialogData, + errorContext: ErrorContextKey.FLOW_VERSION, + clearBannerErrors: clearBannerErrorsMock, + addBannerError: addBannerErrorMock + } + } + ] + }).compileComponents(); + } + + it('should load the initial diff when opened', fakeAsync(() => { + configureTestingModule(); + + const fixture = TestBed.createComponent(FlowDiffDialog); + fixture.detectChanges(); + tick(250); + fixture.detectChanges(); + + expect(getFlowDiffSpy).toHaveBeenCalledWith( + 'registry-id', + 'bucket', + 'flow', + '2', + '1', + null + ); + + const title = fixture.debugElement.query(By.css('h2[mat-dialog-title]')); + expect(title.nativeElement.textContent.trim()).toBe('Flow Version Diff - Sample Flow'); + + const rows = fixture.debugElement.queryAll(By.css('[data-qa="flow-diff-table"] tbody tr')); + expect(rows.length).toBe(1); + + const summaryItems = fixture.debugElement.queryAll(By.css('[data-qa="flow-diff-summary-item"]')); + expect(summaryItems.length).toBe(2); + expect(summaryItems[0].nativeElement.textContent).toContain('Current Version'); + expect(summaryItems[0].nativeElement.textContent).toContain('2'); + expect(summaryItems[0].nativeElement.textContent).toContain('Formatted 2'); + expect(summaryItems[1].nativeElement.textContent).toContain('Selected Version'); + expect(summaryItems[1].nativeElement.textContent).toContain('1'); + expect(summaryItems[1].nativeElement.textContent).toContain('Formatted 1'); + })); + + it('should show empty state when there are no differences', fakeAsync(() => { + const emptyComparison: FlowComparisonEntity = { + componentDifferences: [] + }; + configureTestingModule(); + getFlowDiffSpy.mockReturnValue(of(emptyComparison)); + + const fixture = TestBed.createComponent(FlowDiffDialog); + fixture.detectChanges(); + tick(250); + fixture.detectChanges(); + + const emptyMessage = fixture.debugElement.query(By.css('[data-qa="flow-diff-empty"]')); + expect(emptyMessage).toBeTruthy(); + })); + + it('should display an error when the diff request fails', fakeAsync(() => { + configureTestingModule(); + getFlowDiffSpy.mockReturnValue(throwError(() => new Error('Failed'))); + + const fixture = TestBed.createComponent(FlowDiffDialog); + fixture.detectChanges(); + tick(250); + fixture.detectChanges(); + + const component = fixture.componentInstance; + expect(component.hasError).toBe(true); + expect(clearBannerErrorsMock).toHaveBeenCalled(); + expect(addBannerErrorMock).toHaveBeenCalledWith(['Unable to retrieve version differences.']); + })); +}); diff --git a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/canvas/items/flow/flow-diff-dialog/flow-diff-dialog.ts b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/canvas/items/flow/flow-diff-dialog/flow-diff-dialog.ts new file mode 100644 index 000000000000..93234ca9528e --- /dev/null +++ b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/canvas/items/flow/flow-diff-dialog/flow-diff-dialog.ts @@ -0,0 +1,339 @@ +/* + * 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, DestroyRef, inject } from '@angular/core'; +import { MAT_DIALOG_DATA, MatDialogModule } from '@angular/material/dialog'; +import { MatTableDataSource, MatTableModule } from '@angular/material/table'; +import { MatSortModule, Sort } from '@angular/material/sort'; +import { FormBuilder, FormControl, FormGroup, ReactiveFormsModule } from '@angular/forms'; +import { CommonModule } from '@angular/common'; +import { MatFormField, MatLabel } from '@angular/material/form-field'; +import { MatSelectModule } from '@angular/material/select'; +import { MatOption } from '@angular/material/core'; +import { MatInput } from '@angular/material/input'; +import { MatButton } from '@angular/material/button'; +import { combineLatest, of } from 'rxjs'; +import { catchError, debounceTime, distinctUntilChanged, filter, map, startWith, switchMap, take } from 'rxjs/operators'; +import { FlowComparisonEntity, VersionControlInformation } from '../../../../../state/flow'; +import { VersionedFlowSnapshotMetadata } from '../../../../../../../state/shared'; +import { RegistryService } from '../../../../../service/registry.service'; +import { CloseOnEscapeDialog, NiFiCommon } from '@nifi/shared'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { ContextErrorBanner } from '../../../../../../../ui/common/context-error-banner/context-error-banner.component'; +import { ErrorContextKey } from '../../../../../../../state/error'; + +export interface FlowDiffDialogData { + versionControlInformation: VersionControlInformation; + versions: VersionedFlowSnapshotMetadata[]; + currentVersion: string; + selectedVersion: string; + errorContext: ErrorContextKey; + clearBannerErrors?: () => void; + addBannerError?: (errors: string[]) => void; + formatTimestamp?: (flowVersion: VersionedFlowSnapshotMetadata) => string | undefined; +} + +interface FlowDiffRow { + componentName: string; + changeType: string; + difference: string; +} + +@Component({ + selector: 'flow-diff-dialog', + standalone: true, + imports: [ + CommonModule, + MatDialogModule, + MatTableModule, + MatSortModule, + ReactiveFormsModule, + MatFormField, + MatLabel, + MatSelectModule, + MatOption, + MatInput, + MatButton, + ContextErrorBanner + ], + templateUrl: './flow-diff-dialog.html', + styleUrl: './flow-diff-dialog.scss' +}) +export class FlowDiffDialog extends CloseOnEscapeDialog { + private data = inject(MAT_DIALOG_DATA); + private registryService = inject(RegistryService); + private destroyRef = inject(DestroyRef); + private formBuilder = inject(FormBuilder); + private nifiCommon = inject(NiFiCommon); + + displayedColumns: string[] = ['componentName', 'changeType', 'difference']; + dataSource: MatTableDataSource = new MatTableDataSource(); + comparisonForm: FormGroup; + filterControl: FormControl = new FormControl('', { nonNullable: true }); + currentVersionControl: FormControl; + selectedVersionControl: FormControl; + sort: Sort = { + active: 'componentName', + direction: 'desc' + }; + + versionOptions: string[]; + flowName: string; + comparisonSummary: { label: string; version: string; created?: string }[] = []; + isLoading = false; + hasError = false; + noDifferences = false; + private versionMetadataByVersion: Map = new Map(); + + readonly errorContext: ErrorContextKey; + private clearBannerErrors: () => void; + private addBannerError: (errors: string[]) => void; + private formatTimestampFn?: (flowVersion: VersionedFlowSnapshotMetadata) => string | undefined; + + constructor() { + super(); + const versions = this.sortVersions(this.data.versions); + this.versionOptions = versions.map((version) => version.version); + this.versionMetadataByVersion = new Map(versions.map((metadata) => [metadata.version, metadata])); + const vci = this.data.versionControlInformation; + this.flowName = vci.flowName || vci.flowId; + this.errorContext = this.data.errorContext; + this.clearBannerErrors = this.data.clearBannerErrors ?? (() => {}); + this.addBannerError = this.data.addBannerError ?? (() => {}); + this.formatTimestampFn = this.data.formatTimestamp; + + this.currentVersionControl = new FormControl(this.data.currentVersion, { nonNullable: true }); + this.selectedVersionControl = new FormControl(this.data.selectedVersion, { nonNullable: true }); + this.comparisonForm = this.formBuilder.group({ + currentVersion: this.currentVersionControl, + selectedVersion: this.selectedVersionControl + }); + + this.dataSource.filterPredicate = (row: FlowDiffRow, filterTerm: string) => { + if (!filterTerm) { + return true; + } + + const normalizedFilter = filterTerm.toLowerCase(); + return ( + (row.componentName || '').toLowerCase().includes(normalizedFilter) || + (row.changeType || '').toLowerCase().includes(normalizedFilter) || + (row.difference || '').toLowerCase().includes(normalizedFilter) + ); + }; + + this.dataSource.sortingDataAccessor = (row: FlowDiffRow, property: string) => { + switch (property) { + case 'componentName': + return (row.componentName || '').toLowerCase(); + case 'changeType': + return (row.changeType || '').toLowerCase(); + case 'difference': + return (row.difference || '').toLowerCase(); + default: + return ''; + } + }; + + this.configureFiltering(); + this.configureComparisonChanges(); + this.setComparisonSummary(this.data.currentVersion, this.data.selectedVersion); + } + + formatVersionOption(version: string): string { + const metadata = this.versionMetadataByVersion.get(version); + const formattedVersion = version.length > 5 ? `${version.substring(0, 5)}...` : version; + const created = this.formatTimestampForMetadata(metadata); + return this.nifiCommon.isDefinedAndNotNull(created) + ? `${formattedVersion} (${created})` + : formattedVersion; + } + + private configureFiltering(): void { + this.filterControl.valueChanges + .pipe( + startWith(this.filterControl.value), + debounceTime(200), + takeUntilDestroyed(this.destroyRef) + ) + .subscribe((value) => { + const filterTerm = value ? value.trim() : ''; + this.dataSource.filter = filterTerm.toLowerCase(); + }); + } + + private configureComparisonChanges(): void { + const currentVersion$ = this.currentVersionControl.valueChanges.pipe(startWith(this.currentVersionControl.value)); + const selectedVersion$ = this.selectedVersionControl.valueChanges.pipe( + startWith(this.selectedVersionControl.value) + ); + + combineLatest([currentVersion$, selectedVersion$]) + .pipe( + takeUntilDestroyed(this.destroyRef), + map(([current, selected]) => [current, selected] as [string | null, string | null]), + filter(([current, selected]) => !!current && !!selected), + distinctUntilChanged( + ([currentA, selectedA], [currentB, selectedB]) => + currentA === currentB && selectedA === selectedB + ), + switchMap(([current, selected]) => { + this.isLoading = true; + this.hasError = false; + this.noDifferences = false; + this.clearBannerErrors(); + return this.fetchFlowDiff(current as string, selected as string).pipe( + catchError((error: unknown) => { + this.isLoading = false; + this.hasError = true; + const message = 'Unable to retrieve version differences.'; + this.addBannerError([message]); + console.error('Failed to load flow version diff', error); + this.dataSource.data = []; + this.noDifferences = false; + return of(null); + }) + ); + }) + ) + .subscribe((comparison) => { + if (!comparison) { + return; + } + + this.isLoading = false; + this.hasError = false; + this.setComparisonSummary(this.currentVersionControl.value, this.selectedVersionControl.value); + const rows = this.toRows(comparison); + this.dataSource.data = this.sortRows(rows, this.sort); + this.noDifferences = rows.length === 0; + }); + } + + sortData(sort: Sort): void { + this.sort = sort; + this.dataSource.data = this.sortRows(this.dataSource.data, sort); + } + + private fetchFlowDiff(versionA: string, versionB: string) { + this.setComparisonSummary(versionA, versionB); + const vci = this.data.versionControlInformation; + const branch = vci.branch ?? null; + + return this.registryService + .getFlowDiff(vci.registryId, vci.bucketId, vci.flowId, versionA, versionB, branch) + .pipe(take(1)); + } + + private sortVersions(versions: VersionedFlowSnapshotMetadata[]): VersionedFlowSnapshotMetadata[] { + return versions + .slice() + .sort((a, b) => { + const timestampA = this.nifiCommon.isDefinedAndNotNull(a.timestamp) ? a.timestamp : 0; + const timestampB = this.nifiCommon.isDefinedAndNotNull(b.timestamp) ? b.timestamp : 0; + const timestampComparison = this.nifiCommon.compareNumber(timestampB, timestampA); + if (timestampComparison !== 0) { + return timestampComparison; + } + + if (this.nifiCommon.isNumber(a.version) && this.nifiCommon.isNumber(b.version)) { + return this.nifiCommon.compareNumber(parseInt(b.version, 10), parseInt(a.version, 10)); + } + + return this.nifiCommon.compareString(b.version, a.version); + }); + } + + private toRows(comparison: FlowComparisonEntity): FlowDiffRow[] { + if (!comparison || !comparison.componentDifferences) { + return []; + } + + const rows: FlowDiffRow[] = []; + comparison.componentDifferences.forEach((component) => { + component.differences.forEach((difference) => { + rows.push({ + componentName: component.componentName || '', + changeType: difference.differenceType, + difference: difference.difference + }); + }); + }); + + return rows; + } + + private sortRows(data: FlowDiffRow[], sort: Sort): FlowDiffRow[] { + if (!data) { + return []; + } + + if (!sort.direction) { + return data.slice(); + } + + const direction = sort.direction === 'asc' ? 1 : -1; + return data.slice().sort((a, b) => { + const aValue = this.sortingValue(a, sort.active); + const bValue = this.sortingValue(b, sort.active); + return aValue.localeCompare(bValue) * direction; + }); + } + + private sortingValue(row: FlowDiffRow, property: string): string { + switch (property) { + case 'componentName': + return (row.componentName || '').toLowerCase(); + case 'changeType': + return (row.changeType || '').toLowerCase(); + case 'difference': + return (row.difference || '').toLowerCase(); + default: + return ''; + } + } + + private setComparisonSummary(versionA: string, versionB: string): void { + this.comparisonSummary = [ + this.toSummary('Current Version', versionA), + this.toSummary('Selected Version', versionB) + ]; + } + + private toSummary(label: string, version: string): { label: string; version: string; created?: string } { + const metadata = this.versionMetadataByVersion.get(version); + return { + label, + version, + created: this.formatTimestampForMetadata(metadata) + }; + } + + private formatTimestampForMetadata( + metadata: VersionedFlowSnapshotMetadata | undefined + ): string | undefined { + if (!metadata) { + return undefined; + } + + if (this.formatTimestampFn) { + return this.formatTimestampFn(metadata); + } + + return metadata.timestamp ? metadata.timestamp.toString() : undefined; + } +}