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..354d3e24bf09 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,25 @@ export class RegistryService { ); } + getFlowDiff( + registryId: string, + bucketId: string, + flowId: string, + versionA: string, + versionB: string, + branch?: string | null + ): Observable { + if (branch) { + return this.httpClient.get( + `${RegistryService.API}/flow/registries/${registryId}/branches/${branch}/buckets/${bucketId}/flows/${flowId}/${versionA}/diff/branches/${branch}/buckets/${bucketId}/flows/${flowId}/${versionB}` + ); + } + + return this.httpClient.get( + `${RegistryService.API}/flow/registries/${registryId}/buckets/${bucketId}/flows/${flowId}/diff/${versionA}/${versionB}` + ); + } + 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 22871cb6847b..dad4d88efc40 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 @@ -121,6 +121,32 @@

+ + + + +
+ + + + +
+ +
+ { expect(changeButton.nativeElement.disabled).toBe(false); }); }); + + describe('Flow Diff Dialog', () => { + it('should include actions column in displayed columns', async () => { + const { component } = await setup(); + + expect(component.displayedColumns).toContain('actions'); + }); + + it('should render actions menu button for each version row', async () => { + const { fixture } = await setup(); + const actionButtons = fixture.debugElement.queryAll(By.css('[data-qa="version-actions-button"]')); + + expect(actionButtons.length).toBe(2); + }); + + it('should open FlowDiffDialog when openFlowDiff is called', async () => { + const { component, fixture } = await setup(); + const dialogInstance = fixture.debugElement.injector.get(MatDialog); + const openSpy = vi.spyOn(dialogInstance, 'open').mockReturnValue({} as any); + const flowVersion = component.dataSource.data[1]; + + component.openFlowDiff(flowVersion); + + expect(openSpy).toHaveBeenCalledWith( + FlowDiffDialog, + expect.objectContaining({ + ...LARGE_DIALOG, + autoFocus: false, + data: expect.objectContaining({ + currentVersion: '2', + selectedVersion: flowVersion.version + }) + }) + ); + }); + + it('should pass version control information to FlowDiffDialog', async () => { + const { component, fixture, dialogData } = await setup(); + const dialogInstance = fixture.debugElement.injector.get(MatDialog); + const openSpy = vi.spyOn(dialogInstance, 'open').mockReturnValue({} as any); + const flowVersion = component.dataSource.data[1]; + + component.openFlowDiff(flowVersion); + + const openCall = openSpy.mock.calls[0]; + const passedData = openCall[1]!.data as FlowDiffDialogData; + expect(passedData.versionControlInformation).toEqual(dialogData.versionControlInformation); + }); + + it('should pass all flow versions to FlowDiffDialog', async () => { + const { component, fixture } = await setup(); + const dialogInstance = fixture.debugElement.injector.get(MatDialog); + const openSpy = vi.spyOn(dialogInstance, 'open').mockReturnValue({} as any); + const flowVersion = component.dataSource.data[1]; + + component.openFlowDiff(flowVersion); + + const openCall = openSpy.mock.calls[0]; + const passedData = openCall[1]!.data as FlowDiffDialogData; + expect(passedData.versions).toHaveLength(2); + }); + }); }); 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 bfe76eb2240d..cff158abaaf7 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 @@ -18,7 +18,7 @@ import { Component, EventEmitter, Output, inject } from '@angular/core'; import { MatButton } 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 } from '../../../../../state/flow'; @@ -26,7 +26,10 @@ import { VersionControlInformation } from '../../../../../../../ui/common/toolti 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, LARGE_DIALOG, NifiTooltipDirective, TextTip } from '@nifi/shared'; +import { MatMenu, MatMenuItem, MatMenuTrigger } from '@angular/material/menu'; +import { FlowDiffDialog, FlowDiffDialogData } from '../flow-diff-dialog/flow-diff-dialog'; +import { ErrorContextKey } from '../../../../../../../state/error'; @Component({ selector: 'change-version-dialog', @@ -38,7 +41,10 @@ import { NiFiCommon, CloseOnEscapeDialog, NifiTooltipDirective, TextTip } from ' MatDialogModule, MatSortModule, MatTableModule, - NifiTooltipDirective + NifiTooltipDirective, + MatMenu, + MatMenuItem, + MatMenuTrigger ], templateUrl: './change-version-dialog.html', styleUrl: './change-version-dialog.scss' @@ -48,16 +54,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(); @@ -67,6 +75,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; @@ -152,5 +161,22 @@ export class ChangeVersionDialog extends CloseOnEscapeDialog { return flowVersion.version === this.versionControlInformation.version; } + openFlowDiff(flowVersion: VersionedFlowSnapshotMetadata) { + const dialogData: FlowDiffDialogData = { + versionControlInformation: this.versionControlInformation, + versions: this.allFlowVersions, + currentVersion: this.versionControlInformation.version, + selectedVersion: flowVersion.version, + errorContext: ErrorContextKey.FLOW_DIFF, + 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..8f211bdb1701 --- /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,160 @@ + + +

Flow Version Diff - {{ flowName }}

+
+ + +
+ @if (comparisonSummary.length > 0) { +
+
Comparing versions
+
+ @for (summary of comparisonSummary; track summary.label) { +
+
{{ summary.label }}
+
+ {{ summary.version }} + @if (summary.created) { + ({{ summary.created }}) + } +
+
+ } +
+
+ } + +
+
+
+ + Current Version + + @for (version of versionOptions; track version) { + + {{ formatVersionOption(version) }} + + } + + +
+ +
+ + Selected Version + + @for (version of versionOptions; track version) { + + {{ formatVersionOption(version) }} + + } + + +
+
+ +
+ + Filter + + @if (filterControl.value) { + + } + +
+
+ +
+
+ @if (isLoading) { +
+ +
+ } @else if (!hasError) { + @if (currentVersionControl.value === selectedVersionControl.value) { +
+ Select two different versions to compare. +
+ } @else if (dataSource.filteredData.length > 0) { + + + + + + + + + + + + + + + + + + +
Component Name +
+ {{ row.componentName || 'Unknown' }} +
+
Change Type +
+ {{ row.changeType }} +
+
Difference +
+ {{ row.difference }} +
+
+ } @else if (noDifferences) { +
No differences to display.
+ } @else { +
+ No results match the current filter. +
+ } + } +
+
+
+
+ + + +
\ No newline at end of file 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..35b8006d51de --- /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,354 @@ +/* + * 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 } from '@angular/core/testing'; +import { FlowDiffDialog, FlowDiffDialogData } from './flow-diff-dialog'; +import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { RegistryService } from '../../../../../service/registry.service'; +import { ErrorContextKey, errorFeatureKey } from '../../../../../../../state/error'; +import { initialState as errorInitialState } from '../../../../../../../state/error/error.reducer'; +import { VersionedFlowSnapshotMetadata } from '../../../../../../../state/shared'; +import { FlowComparisonEntity } from '../../../../../state/flow'; +import { of, Subject, throwError } from 'rxjs'; +import { MockStore, provideMockStore } from '@ngrx/store/testing'; +import * as ErrorActions from '../../../../../../../state/error/error.actions'; + +interface SetupOptions { + dialogData?: FlowDiffDialogData; + registryServiceOverrides?: Partial; +} + +describe('FlowDiffDialog', () => { + function createMockVersions(): VersionedFlowSnapshotMetadata[] { + return [ + { + bucketIdentifier: 'bucket-1', + flowIdentifier: 'flow-1', + version: '2', + timestamp: 1712171233843, + author: 'user-a', + comments: 'Second version' + }, + { + bucketIdentifier: 'bucket-1', + flowIdentifier: 'flow-1', + version: '1', + timestamp: 1712076498414, + author: 'user-a', + comments: 'Initial version' + } + ]; + } + + function createMockDialogData(overrides: Partial = {}): FlowDiffDialogData { + return { + versionControlInformation: { + groupId: 'pg-1', + registryId: 'reg-1', + registryName: 'Local Registry', + bucketId: 'bucket-1', + bucketName: 'My Bucket', + flowId: 'flow-1', + flowName: 'Test Flow', + flowDescription: '', + version: '2', + state: 'UP_TO_DATE', + stateExplanation: 'Flow version is current' + }, + versions: createMockVersions(), + currentVersion: '2', + selectedVersion: '1', + errorContext: ErrorContextKey.FLOW_DIFF, + formatTimestamp: (v: VersionedFlowSnapshotMetadata) => `formatted-${v.version}`, + ...overrides + }; + } + + function createMockRegistryService(overrides: Partial = {}): Partial { + return { + getFlowDiff: vi.fn().mockReturnValue( + of({ + componentDifferences: [ + { + componentType: 'Processor', + componentId: 'proc-1', + processGroupId: 'pg-1', + componentName: 'GenerateFlowFile', + differences: [ + { + differenceType: 'Property Value Changed', + difference: 'File Size changed from 0B to 1KB' + } + ] + } + ] + }) + ), + ...overrides + }; + } + + async function setup(options: SetupOptions = {}) { + const dialogData = options.dialogData || createMockDialogData(); + const mockRegistryService = createMockRegistryService(options.registryServiceOverrides); + + await TestBed.configureTestingModule({ + imports: [FlowDiffDialog, NoopAnimationsModule], + providers: [ + { provide: MAT_DIALOG_DATA, useValue: dialogData }, + { provide: MatDialogRef, useValue: null }, + { provide: RegistryService, useValue: mockRegistryService }, + provideMockStore({ + initialState: { + [errorFeatureKey]: errorInitialState + } + }) + ] + }).compileComponents(); + + const store = TestBed.inject(MockStore); + const dispatchSpy = vi.spyOn(store, 'dispatch'); + + const fixture = TestBed.createComponent(FlowDiffDialog); + const component = fixture.componentInstance; + fixture.detectChanges(); + await fixture.whenStable(); + fixture.detectChanges(); + + return { fixture, component, dialogData, mockRegistryService, store, dispatchSpy }; + } + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should create', async () => { + const { component } = await setup(); + + expect(component).toBeTruthy(); + }); + + describe('Component Initialization', () => { + it('should set flow name from version control information', async () => { + const { component } = await setup(); + + expect(component.flowName).toBe('Test Flow'); + }); + + it('should populate version options from provided versions', async () => { + const { component } = await setup(); + + expect(component.versionOptions).toHaveLength(2); + expect(component.versionOptions).toContain('2'); + expect(component.versionOptions).toContain('1'); + }); + + it('should initialize form controls with current and selected versions', async () => { + const { component } = await setup(); + + expect(component.currentVersionControl.value).toBe('2'); + expect(component.selectedVersionControl.value).toBe('1'); + }); + + it('should set comparison summary on initialization', async () => { + const { component } = await setup(); + + expect(component.comparisonSummary).toHaveLength(2); + expect(component.comparisonSummary[0].label).toBe('Current Version'); + expect(component.comparisonSummary[0].version).toBe('2'); + expect(component.comparisonSummary[1].label).toBe('Selected Version'); + expect(component.comparisonSummary[1].version).toBe('1'); + }); + }); + + describe('Flow Diff Loading', () => { + it('should call registry service to load diff on initialization', async () => { + const { mockRegistryService } = await setup(); + + expect(mockRegistryService.getFlowDiff).toHaveBeenCalledWith('reg-1', 'bucket-1', 'flow-1', '2', '1', null); + }); + + it('should populate data source with diff rows', async () => { + const { component } = await setup(); + + expect(component.dataSource.data.length).toBeGreaterThan(0); + expect(component.dataSource.data[0].componentName).toBe('GenerateFlowFile'); + expect(component.dataSource.data[0].changeType).toBe('Property Value Changed'); + expect(component.dataSource.data[0].difference).toBe('File Size changed from 0B to 1KB'); + }); + + it('should set noDifferences when comparison returns empty', async () => { + const { component } = await setup({ + registryServiceOverrides: { + getFlowDiff: vi.fn().mockReturnValue(of({ componentDifferences: [] })) + } + }); + + expect(component.noDifferences).toBe(true); + expect(component.dataSource.data).toHaveLength(0); + }); + + it('should show the loading spinner while the diff request is in flight and hide it once it completes', async () => { + const diffSubject = new Subject(); + const { component, fixture } = await setup({ + registryServiceOverrides: { + getFlowDiff: vi.fn().mockReturnValue(diffSubject.asObservable()) + } + }); + + expect(component.isLoading).toBe(true); + expect(fixture.nativeElement.querySelector('[data-qa="flow-diff-loading"]')).toBeTruthy(); + + diffSubject.next({ componentDifferences: [] }); + diffSubject.complete(); + + expect(component.isLoading).toBe(false); + }); + + it('should handle error when loading diff', async () => { + const { component, dispatchSpy } = await setup({ + registryServiceOverrides: { + getFlowDiff: vi.fn().mockReturnValue(throwError(() => new Error('Network error'))) + } + }); + + expect(component.hasError).toBe(true); + expect(component.dataSource.data).toHaveLength(0); + expect(dispatchSpy).toHaveBeenCalledWith( + ErrorActions.addBannerError({ + errorContext: { + context: ErrorContextKey.FLOW_DIFF, + errors: ['Unable to retrieve version differences.'] + } + }) + ); + }); + + it('should clear banner errors for the flow diff context when starting a comparison', async () => { + const { dispatchSpy } = await setup(); + + expect(dispatchSpy).toHaveBeenCalledWith( + ErrorActions.clearBannerErrors({ context: ErrorContextKey.FLOW_DIFF }) + ); + }); + + it('should not fetch diff and should clear the summary when both versions are equal', async () => { + const { component, mockRegistryService } = await setup({ + dialogData: createMockDialogData({ currentVersion: '2', selectedVersion: '2' }) + }); + + expect(mockRegistryService.getFlowDiff).not.toHaveBeenCalled(); + expect(component.comparisonSummary).toHaveLength(0); + }); + }); + + describe('Version Option Formatting', () => { + it('should format version option with timestamp', async () => { + const { component } = await setup(); + + const formatted = component.formatVersionOption('2'); + + expect(formatted).toContain('2'); + expect(formatted).toContain('formatted-2'); + }); + + it('should truncate long version strings', async () => { + const dialogData = createMockDialogData({ + versions: [ + { + bucketIdentifier: 'bucket-1', + flowIdentifier: 'flow-1', + version: 'very-long-version-string', + timestamp: 1712171233843, + author: 'user-a', + comments: '' + } + ], + currentVersion: 'very-long-version-string', + selectedVersion: 'very-long-version-string' + }); + const { component } = await setup({ dialogData }); + + const formatted = component.formatVersionOption('very-long-version-string'); + + expect(formatted).toContain('very-...'); + }); + }); + + describe('Sorting', () => { + it('should sort data when sortData is called', async () => { + const mockService = createMockRegistryService({ + getFlowDiff: vi.fn().mockReturnValue( + of({ + componentDifferences: [ + { + componentType: 'Processor', + componentId: 'proc-1', + processGroupId: 'pg-1', + componentName: 'Bravo', + differences: [{ differenceType: 'Added', difference: 'Added component' }] + }, + { + componentType: 'Processor', + componentId: 'proc-2', + processGroupId: 'pg-1', + componentName: 'Alpha', + differences: [{ differenceType: 'Removed', difference: 'Removed component' }] + } + ] + }) + ) + }); + const { component } = await setup({ registryServiceOverrides: mockService }); + + component.sortData({ active: 'componentName', direction: 'asc' }); + + expect(component.sort.active).toBe('componentName'); + expect(component.sort.direction).toBe('asc'); + expect(component.dataSource.data[0].componentName).toBe('Alpha'); + expect(component.dataSource.data[1].componentName).toBe('Bravo'); + }); + }); + + describe('Filtering', () => { + beforeEach(() => vi.useFakeTimers()); + afterEach(() => vi.useRealTimers()); + + it('should filter data source after debounce', async () => { + const { component } = await setup(); + const initialCount = component.dataSource.filteredData.length; + expect(initialCount).toBeGreaterThan(0); + + component.filterControl.setValue('nonexistent-term-xyz'); + vi.advanceTimersByTime(200); + + expect(component.dataSource.filter).toBe('nonexistent-term-xyz'); + expect(component.dataSource.filteredData.length).toBe(0); + }); + + it('should show matching rows when filter matches', async () => { + const { component } = await setup(); + + component.filterControl.setValue('GenerateFlowFile'); + vi.advanceTimersByTime(200); + + expect(component.dataSource.filteredData.length).toBe(1); + expect(component.dataSource.filteredData[0].componentName).toBe('GenerateFlowFile'); + }); + }); +}); \ No newline at end of file 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..25426ff8316e --- /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,320 @@ +/* + * 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 { FormControl, ReactiveFormsModule } from '@angular/forms'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatSelectModule } from '@angular/material/select'; +import { MatInputModule } from '@angular/material/input'; +import { MatButtonModule } from '@angular/material/button'; +import { combineLatest, of } from 'rxjs'; +import { + catchError, + debounceTime, + distinctUntilChanged, + filter, + map, + startWith, + switchMap, + take, + tap +} from 'rxjs/operators'; +import { FlowComparisonEntity } from '../../../../../state/flow'; +import { VersionedFlowSnapshotMetadata } from '../../../../../../../state/shared'; +import { VersionControlInformation } from '../../../../../../../ui/common/tooltips/version-control-tip/version-control-tip.component'; +import { RegistryService } from '../../../../../service/registry.service'; +import { CloseOnEscapeDialog, NiFiCommon, NifiSpinnerDirective } from '@nifi/shared'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { Store } from '@ngrx/store'; +import { ContextErrorBanner } from '../../../../../../../ui/common/context-error-banner/context-error-banner.component'; +import { ErrorContextKey } from '../../../../../../../state/error'; +import * as ErrorActions from '../../../../../../../state/error/error.actions'; +import { NiFiState } from '../../../../../../../state'; + +export interface FlowDiffDialogData { + versionControlInformation: VersionControlInformation; + versions: VersionedFlowSnapshotMetadata[]; + currentVersion: string; + selectedVersion: string; + errorContext: ErrorContextKey; + formatTimestamp?: (flowVersion: VersionedFlowSnapshotMetadata) => string | undefined; +} + +interface FlowDiffRow { + componentName: string; + changeType: string; + difference: string; +} + +@Component({ + selector: 'flow-diff-dialog', + imports: [ + MatDialogModule, + MatTableModule, + MatSortModule, + ReactiveFormsModule, + MatFormFieldModule, + MatSelectModule, + MatInputModule, + MatButtonModule, + NifiSpinnerDirective, + ContextErrorBanner + ], + templateUrl: './flow-diff-dialog.html' +}) +export class FlowDiffDialog extends CloseOnEscapeDialog { + private data = inject(MAT_DIALOG_DATA); + private registryService = inject(RegistryService); + private destroyRef = inject(DestroyRef); + private nifiCommon = inject(NiFiCommon); + private store = inject>(Store); + + displayedColumns: string[] = ['componentName', 'changeType', 'difference']; + dataSource: MatTableDataSource = new MatTableDataSource(); + 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 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.formatTimestampFn = this.data.formatTimestamp; + + this.currentVersionControl = new FormControl(this.data.currentVersion, { nonNullable: true }); + this.selectedVersionControl = new FormControl(this.data.selectedVersion, { nonNullable: true }); + + 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.configureFiltering(); + this.configureComparisonChanges(); + } + + 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]), + tap(([current, selected]) => { + if (current && selected && current === selected) { + this.comparisonSummary = []; + } + }), + filter(([current, selected]) => !!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.store.dispatch(ErrorActions.clearBannerErrors({ context: this.errorContext })); + 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.store.dispatch( + ErrorActions.addBannerError({ + errorContext: { + context: this.errorContext, + errors: [message] + } + }) + ); + 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) { + 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; + } +} \ No newline at end of file diff --git a/nifi-frontend/src/main/frontend/apps/nifi/src/app/state/error/index.ts b/nifi-frontend/src/main/frontend/apps/nifi/src/app/state/error/index.ts index 3a236560ffb4..0ad7f3dfd56e 100644 --- a/nifi-frontend/src/main/frontend/apps/nifi/src/app/state/error/index.ts +++ b/nifi-frontend/src/main/frontend/apps/nifi/src/app/state/error/index.ts @@ -46,6 +46,7 @@ export enum ErrorContextKey { REGISTRY_IMPORT = 'registry-import', LABEL = 'label', FLOW_VERSION = 'flow-version', + FLOW_DIFF = 'flow-diff', FUNNEL = 'funnel', LOCAL_EXTENSIONS = 'local-extensions', LINEAGE = 'lineage',