diff --git a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/service/canvas-context-menu.service.ts b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/service/canvas-context-menu.service.ts index b85d1c06eb85..9d59daf34b4f 100644 --- a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/service/canvas-context-menu.service.ts +++ b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/service/canvas-context-menu.service.ts @@ -39,6 +39,7 @@ import { navigateToViewStatusHistoryForComponent, openChangeProcessorVersionDialog, openChangeVersionDialogRequest, + openCreateBranchDialogRequest, openCommitLocalChangesDialogRequest, openForceCommitLocalChangesDialogRequest, openRevertLocalChangesDialogRequest, @@ -215,6 +216,28 @@ export class CanvasContextMenu implements ContextMenuDefinitionProvider { this.store.dispatch(openChangeVersionDialogRequest({ request })); } }, + { + condition: (selection: d3.Selection) => { + return this.canvasUtils.supportsCreateFlowBranch(selection); + }, + clazz: 'fa fa-code-fork', + text: 'Create Branch', + action: (selection: d3.Selection) => { + let pgId; + if (selection.empty()) { + pgId = this.canvasUtils.getProcessGroupId(); + } else { + pgId = selection.datum().id; + } + this.store.dispatch( + openCreateBranchDialogRequest({ + request: { + processGroupId: pgId + } + }) + ); + } + }, { isSeparator: true }, diff --git a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/service/canvas-utils.service.spec.ts b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/service/canvas-utils.service.spec.ts index 4c48a436d42d..6975db869a79 100644 --- a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/service/canvas-utils.service.spec.ts +++ b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/service/canvas-utils.service.spec.ts @@ -24,7 +24,12 @@ import * as fromFlow from '../state/flow/flow.reducer'; import { transformFeatureKey } from '../state/transform'; import * as fromTransform from '../state/transform/transform.reducer'; import { MockStore, provideMockStore } from '@ngrx/store/testing'; -import { selectConnections, selectCurrentProcessGroupId, selectFlowState } from '../state/flow/flow.selectors'; +import { + selectConnections, + selectCurrentProcessGroupId, + selectFlowState, + selectRegistryClients +} from '../state/flow/flow.selectors'; import { controllerServicesFeatureKey } from '../state/controller-services'; import * as fromControllerServices from '../state/controller-services/controller-services.reducer'; import { selectCurrentUser } from '../../../state/current-user/current-user.selectors'; @@ -131,6 +136,91 @@ describe('CanvasUtils', () => { }); }); + describe('supportsCreateFlowBranch', () => { + const registryId = '324e0ab1-0197-1000-ffff-ffffb3123c5c'; + + function createProcessGroupSelection(versionControlInformation: any): d3.Selection { + const pgDatum = { + id: '1', + type: ComponentType.ProcessGroup, + permissions: { canRead: true, canWrite: true }, + component: { + id: '1', + name: 'Test Process Group', + versionControlInformation + } + }; + return d3.select(document.createElement('div')).classed('process-group', true).datum(pgDatum); + } + + function configure(canVersionFlows: boolean, supportsBranching: boolean): void { + const store = TestBed.inject(MockStore); + store.overrideSelector(selectCurrentUser, { ...fromUser.initialState.user, canVersionFlows }); + store.overrideSelector(selectRegistryClients, [ + { id: registryId, component: { supportsBranching } } + ] as any); + store.refreshState(); + } + + it('should return false when the user cannot version flows', () => { + configure(false, true); + const selection = createProcessGroupSelection({ groupId: '1', registryId, state: 'UP_TO_DATE' }); + expect(service.supportsCreateFlowBranch(selection)).toBe(false); + }); + + it('should return false when there is no version control information', () => { + configure(true, true); + const selection = createProcessGroupSelection(null); + expect(service.supportsCreateFlowBranch(selection)).toBe(false); + }); + + it('should return false when the registry id is missing', () => { + configure(true, true); + const selection = createProcessGroupSelection({ groupId: '1', state: 'UP_TO_DATE' }); + expect(service.supportsCreateFlowBranch(selection)).toBe(false); + }); + + it('should return false when the flow is in a sync failure state', () => { + configure(true, true); + const selection = createProcessGroupSelection({ groupId: '1', registryId, state: 'SYNC_FAILURE' }); + expect(service.supportsCreateFlowBranch(selection)).toBe(false); + }); + + it('should return false when the registry client does not support branching', () => { + configure(true, false); + const selection = createProcessGroupSelection({ groupId: '1', registryId, state: 'UP_TO_DATE' }); + expect(service.supportsCreateFlowBranch(selection)).toBe(false); + }); + + it('should return true when version controlled and the registry client supports branching', () => { + configure(true, true); + const selection = createProcessGroupSelection({ groupId: '1', registryId, state: 'UP_TO_DATE' }); + expect(service.supportsCreateFlowBranch(selection)).toBe(true); + }); + + it('should return true when the flow is locally modified', () => { + configure(true, true); + const selection = createProcessGroupSelection({ groupId: '1', registryId, state: 'LOCALLY_MODIFIED' }); + expect(service.supportsCreateFlowBranch(selection)).toBe(true); + }); + + it('should return true when the flow is locally modified and stale', () => { + configure(true, true); + const selection = createProcessGroupSelection({ + groupId: '1', + registryId, + state: 'LOCALLY_MODIFIED_AND_STALE' + }); + expect(service.supportsCreateFlowBranch(selection)).toBe(true); + }); + + it('should return true when the flow is stale', () => { + configure(true, true); + const selection = createProcessGroupSelection({ groupId: '1', registryId, state: 'STALE' }); + expect(service.supportsCreateFlowBranch(selection)).toBe(true); + }); + }); + describe('isStoppable', () => { it('should return false for empty selection', () => { const emptySelection = d3.select(null); diff --git a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/service/canvas-utils.service.ts b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/service/canvas-utils.service.ts index b8847fc7271e..65df63978077 100644 --- a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/service/canvas-utils.service.ts +++ b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/service/canvas-utils.service.ts @@ -26,13 +26,14 @@ import { selectConnections, selectCurrentParameterContext, selectCurrentProcessGroupId, - selectParentProcessGroupId + selectParentProcessGroupId, + selectRegistryClients } from '../state/flow/flow.selectors'; import { initialState as initialFlowState } from '../state/flow/flow.reducer'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { BulletinsTip } from '../../../ui/common/tooltips/bulletins-tip/bulletins-tip.component'; import { Position } from '../state/shared'; -import { BreadcrumbEntity } from '../../../state/shared'; +import { BreadcrumbEntity, RegistryClientEntity } from '../../../state/shared'; import { BulletinEntity, ComponentType, NiFiCommon, ParameterContextReferenceEntity, Permissions } from '@nifi/shared'; import { CurrentUser } from '../../../state/current-user'; import { initialState as initialUserState } from '../../../state/current-user/current-user.reducer'; @@ -89,6 +90,7 @@ export class CanvasUtils { private connections: any[] = []; private breadcrumbs: BreadcrumbEntity | null = null; private copiedSnippet: CopiedSnippet | null = null; + private registryClients: RegistryClientEntity[] = initialFlowState.registryClients; private readonly humanizeDuration: Humanizer; @@ -158,6 +160,13 @@ export class CanvasUtils { .subscribe((scale) => { this.scale = scale; }); + + this.store + .select(selectRegistryClients) + .pipe(takeUntilDestroyed(this.destroyRef)) + .subscribe((registryClients) => { + this.registryClients = registryClients; + }); } public hasDownstream(selection: any): boolean { @@ -2172,6 +2181,32 @@ export class CanvasUtils { ); } + /** + * Returns whether the process group supports creating a new branch. This requires that the + * process group is under version control with a registry client that supports branching. + * + * @argument {d3.Selection} selection The selection + * @return {boolean} Whether the selection supports creating a branch. + */ + public supportsCreateFlowBranch(selection: d3.Selection): boolean { + if (!this.canVersionFlows()) { + return false; + } + + const versionControlInformation = this.getFlowVersionControlInformation(selection); + + if (!versionControlInformation || !versionControlInformation.registryId) { + return false; + } + + if (versionControlInformation.state === 'SYNC_FAILURE') { + return false; + } + + const registryClient = this.registryClients.find((client) => client.id === versionControlInformation.registryId); + return !!registryClient?.component.supportsBranching; + } + /** * Determines whether the current selection supports stopping flow versioning. * diff --git a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/service/flow.service.ts b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/service/flow.service.ts index 48eb10db1f12..859b40c4ddcf 100644 --- a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/service/flow.service.ts +++ b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/service/flow.service.ts @@ -25,6 +25,7 @@ import { CreateComponentRequest, CreateComponentResponse, CreateConnection, + CreateFlowBranchRequest, CreateLabelRequest, CreatePortRequest, CreateProcessGroupRequest, @@ -50,6 +51,7 @@ import { } from '../state/flow'; import { Client } from '../../../service/client.service'; import { ComponentType, NiFiCommon } from '@nifi/shared'; +import { Revision } from '@nifi/shared'; import { ClusterConnectionService } from '../../../service/cluster-connection.service'; import { ClearBulletinsRequest, @@ -415,6 +417,33 @@ export class FlowService implements PropertyDescriptorRetriever { ) as Observable; } + createFlowBranch(request: CreateFlowBranchRequest): Observable { + const payload: { + processGroupRevision: Revision; + branch: string; + disconnectedNodeAcknowledged: boolean; + sourceBranch?: string; + sourceVersion?: string; + } = { + processGroupRevision: request.revision, + branch: request.branch, + disconnectedNodeAcknowledged: this.clusterConnectionService.isDisconnectionAcknowledged() + }; + + if (request.sourceBranch) { + payload.sourceBranch = request.sourceBranch; + } + + if (request.sourceVersion) { + payload.sourceVersion = request.sourceVersion; + } + + return this.httpClient.post( + `${FlowService.API}/versions/process-groups/${request.processGroupId}/branches`, + payload + ) as Observable; + } + stopVersionControl(request: StopVersionControlRequest): Observable { const params: any = { version: request.revision.version, diff --git a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/state/flow/flow.actions.ts b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/state/flow/flow.actions.ts index 39cb505c0677..9de971fbb8f9 100644 --- a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/state/flow/flow.actions.ts +++ b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/state/flow/flow.actions.ts @@ -16,6 +16,7 @@ */ import { createAction, props } from '@ngrx/store'; +import { HttpErrorResponse } from '@angular/common/http'; import { CenterComponentRequest, ChangeColorRequest, @@ -28,6 +29,8 @@ import { CreateComponentResponse, CreateConnection, CreateConnectionRequest, + CreateBranchDialogRequest, + CreateFlowBranchRequest, CreatePortRequest, CreateProcessGroupDialogRequest, CreateProcessGroupRequest, @@ -71,6 +74,7 @@ import { NavigateToQueueListing, OpenChangeVersionDialogRequest, OpenComponentDialogRequest, + OpenCreateBranchDialogRequest, OpenGroupComponentsDialogRequest, OpenLocalChangesDialogRequest, OpenSaveVersionDialogRequest, @@ -847,6 +851,31 @@ export const saveToFlowRegistrySuccess = createAction( props<{ response: VersionControlInformationEntity }>() ); +export const openCreateBranchDialogRequest = createAction( + `${CANVAS_PREFIX} Open Create Branch Dialog Request`, + props<{ request: OpenCreateBranchDialogRequest }>() +); + +export const openCreateBranchDialog = createAction( + `${CANVAS_PREFIX} Open Create Branch Dialog`, + props<{ request: CreateBranchDialogRequest }>() +); + +export const createFlowBranch = createAction( + `${CANVAS_PREFIX} Create Flow Branch`, + props<{ request: CreateFlowBranchRequest }>() +); + +export const createFlowBranchSuccess = createAction( + `${CANVAS_PREFIX} Create Flow Branch Success`, + props<{ response: VersionControlInformationEntity }>() +); + +export const createFlowBranchFailure = createAction( + `${CANVAS_PREFIX} Create Flow Branch Failure`, + props<{ errorResponse: HttpErrorResponse }>() +); + export const stopVersionControlRequest = createAction( `${CANVAS_PREFIX} Stop Version Control Request`, props<{ request: ConfirmStopVersionControlRequest }>() diff --git a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/state/flow/flow.effects.ts b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/state/flow/flow.effects.ts index f888de8579fe..e810e99ec12b 100644 --- a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/state/flow/flow.effects.ts +++ b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/state/flow/flow.effects.ts @@ -52,6 +52,7 @@ import { } from 'rxjs'; import { ComponentEntity, + CreateBranchDialogRequest, CreateConnectionDialogRequest, CreateProcessGroupDialogRequest, DeleteComponentResponse, @@ -154,6 +155,7 @@ import { selectPrioritizerTypes } from '../../../../state/extension-types/extens import { NoRegistryClientsDialog } from '../../ui/common/no-registry-clients-dialog/no-registry-clients-dialog.component'; import { EditRemoteProcessGroup } from '../../../../ui/common/component-dialogs/edit-remote-process-group/edit-remote-process-group.component'; import { HttpErrorResponse } from '@angular/common/http'; +import { CreateBranchDialog } from '../../ui/canvas/items/flow/create-branch-dialog/create-branch-dialog.component'; import { SaveVersionDialog } from '../../ui/canvas/items/flow/save-version-dialog/save-version-dialog.component'; import { ChangeVersionDialog } from '../../ui/canvas/items/flow/change-version-dialog/change-version-dialog'; import { ChangeVersionProgressDialog } from '../../ui/canvas/items/flow/change-version-progress-dialog/change-version-progress-dialog'; @@ -4001,6 +4003,118 @@ export class FlowEffects { ) ); + ///////////////////////////////// + // Create branch effects + ///////////////////////////////// + + openCreateBranchDialogRequest$ = createEffect(() => + this.actions$.pipe( + ofType(FlowActions.openCreateBranchDialogRequest), + map((action) => action.request), + switchMap((request) => + from(this.flowService.getVersionInformation(request.processGroupId)).pipe( + map((response) => { + const versionControlInformation = response.versionControlInformation; + if (!versionControlInformation) { + return FlowActions.showOkDialog({ + title: 'Create Branch', + message: 'Process Group is no longer under version control.' + }); + } + + return FlowActions.openCreateBranchDialog({ + request: { + processGroupId: request.processGroupId, + revision: response.processGroupRevision, + versionControlInformation + } + }); + }), + catchError((errorResponse: HttpErrorResponse) => of(this.snackBarOrFullScreenError(errorResponse))) + ) + ) + ) + ); + + openCreateBranchDialog$ = createEffect( + () => + this.actions$.pipe( + ofType(FlowActions.openCreateBranchDialog), + map((action) => action.request), + tap((request: CreateBranchDialogRequest) => { + const dialogReference = this.dialog.open(CreateBranchDialog, { + ...MEDIUM_DIALOG, + data: request, + autoFocus: true + }); + + dialogReference.componentInstance.saving = this.store.selectSignal(selectVersionSaving); + + dialogReference.componentInstance.createBranch + .pipe(takeUntil(dialogReference.afterClosed())) + .subscribe((branch: string) => { + this.store.dispatch( + FlowActions.createFlowBranch({ + request: { + processGroupId: request.processGroupId, + revision: request.revision, + branch, + sourceBranch: request.versionControlInformation.branch, + sourceVersion: request.versionControlInformation.version + } + }) + ); + }); + }) + ), + { dispatch: false } + ); + + createFlowBranch$ = createEffect(() => + this.actions$.pipe( + ofType(FlowActions.createFlowBranch), + map((action) => action.request), + switchMap((request) => + from(this.flowService.createFlowBranch(request)).pipe( + map((response) => FlowActions.createFlowBranchSuccess({ response })), + catchError((errorResponse: HttpErrorResponse) => + of(FlowActions.createFlowBranchFailure({ errorResponse })) + ) + ) + ) + ) + ); + + createFlowBranchSuccess$ = createEffect(() => + this.actions$.pipe( + ofType(FlowActions.createFlowBranchSuccess), + map((action) => action.response), + tap((response) => { + this.dialog.closeAll(); + const branch = response.versionControlInformation?.branch; + const message = branch + ? `Process Group is now tracking branch ${branch}.` + : 'Branch creation completed successfully.'; + + this.store.dispatch( + FlowActions.showOkDialog({ + title: 'Create Branch', + message + }) + ); + }), + switchMap(() => of(FlowActions.reloadFlow())) + ) + ); + + createFlowBranchFailure$ = createEffect(() => + this.actions$.pipe( + ofType(FlowActions.createFlowBranchFailure), + map((action) => action.errorResponse), + switchMap((errorResponse) => of(this.bannerOrFullScreenError(errorResponse, ErrorContextKey.FLOW_BRANCH))) + ) + ); + flowBannerError$ = createEffect(() => this.actions$.pipe( ofType(FlowActions.flowBannerError), diff --git a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/state/flow/flow.reducer.ts b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/state/flow/flow.reducer.ts index f9d0122704c1..bfb6da7fb3de 100644 --- a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/state/flow/flow.reducer.ts +++ b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/state/flow/flow.reducer.ts @@ -24,6 +24,9 @@ import { createComponentSuccess, createConnection, createFunnel, + createFlowBranch, + createFlowBranchFailure, + createFlowBranchSuccess, createLabel, createPort, createProcessGroup, @@ -604,10 +607,35 @@ export const flowReducer = createReducer( draftState.saving = false; }); }), - on(saveToFlowRegistry, stopVersionControl, (state) => ({ + on(saveToFlowRegistry, stopVersionControl, createFlowBranch, (state) => ({ ...state, versionSaving: true })), + on(createFlowBranchSuccess, (state, { response }) => { + return produce(state, (draftState) => { + const collection: any[] | null = getComponentCollection(draftState, ComponentType.ProcessGroup); + + if (collection) { + const componentIndex: number = collection.findIndex( + (f: any) => response.versionControlInformation?.groupId === f.id + ); + if (componentIndex > -1) { + collection[componentIndex].revision = response.processGroupRevision; + collection[componentIndex].versionedFlowState = response.versionControlInformation?.state; + if (collection[componentIndex].component) { + collection[componentIndex].component.versionControlInformation = + response.versionControlInformation; + } + } + } + + draftState.versionSaving = false; + }); + }), + on(createFlowBranchFailure, (state) => ({ + ...state, + versionSaving: false + })), on(saveToFlowRegistrySuccess, (state, { response }) => { return produce(state, (draftState) => { const collection: any[] | null = getComponentCollection(draftState, ComponentType.ProcessGroup); diff --git a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/state/flow/index.ts b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/state/flow/index.ts index 867fdb42e225..89e6ad7ca0b0 100644 --- a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/state/flow/index.ts +++ b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/state/flow/index.ts @@ -160,6 +160,10 @@ export interface OpenChangeVersionDialogRequest { processGroupId: string; } +export interface OpenCreateBranchDialogRequest { + processGroupId: string; +} + export interface ChangeVersionDialogRequest { processGroupId: string; revision: Revision; @@ -167,6 +171,12 @@ export interface ChangeVersionDialogRequest { versions: VersionedFlowSnapshotMetadataEntity[]; } +export interface CreateBranchDialogRequest { + processGroupId: string; + revision: Revision; + versionControlInformation: VersionControlInformation; +} + export interface SaveVersionDialogRequest { processGroupId: string; revision: Revision; @@ -190,6 +200,14 @@ export interface StopVersionControlRequest { processGroupId: string; } +export interface CreateFlowBranchRequest { + processGroupId: string; + revision: Revision; + branch: string; + sourceBranch?: string; + sourceVersion?: string; +} + export interface StopVersionControlResponse { processGroupId: string; processGroupRevision: Revision; diff --git a/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/canvas/items/flow/create-branch-dialog/create-branch-dialog.component.html b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/canvas/items/flow/create-branch-dialog/create-branch-dialog.component.html new file mode 100644 index 000000000000..6e8ffc719d0b --- /dev/null +++ b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/canvas/items/flow/create-branch-dialog/create-branch-dialog.component.html @@ -0,0 +1,57 @@ + + +

Create Branch

+
+ + +
+
+
Current branch
+
{{ currentBranch || 'Not specified' }}
+
+ +
+ + Branch name + + @if (createBranchForm.controls['branch'].hasError('required')) { + Branch name is required. + } + @if (createBranchForm.controls['branch'].hasError('pattern')) { + Branch name cannot start with a space. + } + @if (createBranchForm.controls['branch'].hasError('branchConflicts')) { + Must differ from current branch. + } + +
+
+
+ + + + + +
\ 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/create-branch-dialog/create-branch-dialog.component.scss b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/canvas/items/flow/create-branch-dialog/create-branch-dialog.component.scss new file mode 100644 index 000000000000..c97e2ea5fc82 --- /dev/null +++ b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/canvas/items/flow/create-branch-dialog/create-branch-dialog.component.scss @@ -0,0 +1,26 @@ +/*! + * 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; + +.create-branch-form { + @include mat.button-density(-1); + + .mat-mdc-form-field { + width: 100%; + } +} \ 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/create-branch-dialog/create-branch-dialog.component.spec.ts b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/canvas/items/flow/create-branch-dialog/create-branch-dialog.component.spec.ts new file mode 100644 index 000000000000..895fe7eca805 --- /dev/null +++ b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/canvas/items/flow/create-branch-dialog/create-branch-dialog.component.spec.ts @@ -0,0 +1,153 @@ +/* + * 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 { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { Signal, signal } from '@angular/core'; +import { CreateBranchDialog } from './create-branch-dialog.component'; +import { MAT_DIALOG_DATA, MatDialogModule, MatDialogRef } from '@angular/material/dialog'; +import { CreateBranchDialogRequest } from '../../../../../state/flow'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { provideMockStore } from '@ngrx/store/testing'; +import { initialState } from '../../../../../state/flow/flow.reducer'; +import { canvasFeatureKey, flowFeatureKey } from '../../../../../state'; +import { errorFeatureKey } from '../../../../../../../state/error'; +import { initialState as errorInitialState } from '../../../../../../../state/error/error.reducer'; + +describe('CreateBranchDialog', () => { + let component: CreateBranchDialog; + let fixture: ComponentFixture; + + const data: CreateBranchDialogRequest = { + processGroupId: '5752a5ae-018d-1000-0990-c3709f5466f3', + revision: { + version: 0 + }, + versionControlInformation: { + groupId: '5752a5ae-018d-1000-0990-c3709f5466f3', + registryId: '324e0ab1-0197-1000-ffff-ffffb3123c5c', + registryName: 'ConnectorFlowRegistryClient', + branch: 'main', + bucketId: 'connectors', + bucketName: 'connectors', + flowId: 'kafka', + flowName: 'kafka', + flowDescription: '', + version: '0.1.0', + state: 'UP_TO_DATE', + stateExplanation: 'Flow version is current' + } + }; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [CreateBranchDialog, MatDialogModule, NoopAnimationsModule], + providers: [ + { + provide: MAT_DIALOG_DATA, + useValue: data + }, + provideMockStore({ + initialState: { + [canvasFeatureKey]: { + [flowFeatureKey]: initialState + }, + [errorFeatureKey]: errorInitialState + } + }), + { provide: MatDialogRef, useValue: null } + ] + }).compileComponents(); + + fixture = TestBed.createComponent(CreateBranchDialog); + component = fixture.componentInstance; + component.saving = (() => false) as Signal; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + expect(component.currentBranch).toBe('main'); + }); + + it('should be invalid when the branch name is empty', () => { + component.createBranchForm.controls['branch'].setValue(''); + expect(component.createBranchForm.invalid).toBe(true); + expect(component.createBranchForm.controls['branch'].hasError('required')).toBe(true); + }); + + it('should be invalid when the branch name starts with whitespace', () => { + component.createBranchForm.controls['branch'].setValue(' feature'); + expect(component.createBranchForm.controls['branch'].hasError('pattern')).toBe(true); + }); + + it('should be invalid when the branch name matches the current branch', () => { + component.createBranchForm.controls['branch'].setValue('main'); + expect(component.createBranchForm.controls['branch'].hasError('branchConflicts')).toBe(true); + }); + + it('should show an error and disable Create when the branch name matches the current branch', () => { + component.createBranchForm.controls['branch'].setValue('main'); + fixture.detectChanges(); + + const error: HTMLElement = fixture.nativeElement.querySelector('mat-error'); + const btn: HTMLButtonElement = fixture.nativeElement.querySelector('button[aria-label="Create"]'); + + expect(error.textContent).toContain('Must differ from current branch.'); + expect(btn.disabled).toBe(true); + }); + + it('should emit the trimmed branch name when the form is valid', () => { + const emitted: string[] = []; + component.createBranch.subscribe((branch) => emitted.push(branch)); + + component.createBranchForm.controls['branch'].setValue('feature-branch'); + component.submitForm(); + + expect(emitted).toEqual(['feature-branch']); + }); + + it('should not emit when the form is invalid', () => { + const emitted: string[] = []; + component.createBranch.subscribe((branch) => emitted.push(branch)); + + component.createBranchForm.controls['branch'].setValue('main'); + component.submitForm(); + + expect(emitted).toEqual([]); + }); + + it('should disable the Create button while a request is in flight', () => { + const saving = signal(true); + component.saving = saving; + component.createBranchForm.controls['branch'].setValue('feature-branch'); + fixture.detectChanges(); + const btn: HTMLButtonElement = fixture.nativeElement.querySelector('button[aria-label="Create"]'); + expect(btn.disabled).toBe(true); + }); + + it('should re-enable the Create button when the request completes', () => { + const saving = signal(true); + component.saving = saving; + component.createBranchForm.controls['branch'].setValue('feature-branch'); + fixture.detectChanges(); + saving.set(false); + fixture.detectChanges(); + const btn: HTMLButtonElement = fixture.nativeElement.querySelector('button[aria-label="Create"]'); + expect(btn.disabled).toBe(false); + }); +}); \ 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/create-branch-dialog/create-branch-dialog.component.ts b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/canvas/items/flow/create-branch-dialog/create-branch-dialog.component.ts new file mode 100644 index 000000000000..ca2ff8c0bf39 --- /dev/null +++ b/nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/canvas/items/flow/create-branch-dialog/create-branch-dialog.component.ts @@ -0,0 +1,115 @@ +/* + * 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, EventEmitter, Input, Output, Signal, inject } from '@angular/core'; +import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; +import { + MAT_DIALOG_DATA, + MatDialogActions, + MatDialogClose, + MatDialogContent, + MatDialogTitle +} from '@angular/material/dialog'; +import { + AbstractControl, + FormBuilder, + FormGroup, + ReactiveFormsModule, + ValidationErrors, + Validators +} from '@angular/forms'; +import { MatButton } from '@angular/material/button'; +import { MatError, MatFormField, MatLabel } from '@angular/material/form-field'; +import { MatInput } from '@angular/material/input'; +import { CloseOnEscapeDialog, NifiSpinnerDirective } from '@nifi/shared'; +import { CreateBranchDialogRequest } from '../../../../../state/flow'; +import { ErrorContextKey } from '../../../../../../../state/error'; +import { ContextErrorBanner } from '../../../../../../../ui/common/context-error-banner/context-error-banner.component'; + +@Component({ + selector: 'create-branch-dialog', + imports: [ + MatDialogTitle, + MatDialogContent, + MatDialogActions, + MatDialogClose, + ReactiveFormsModule, + MatButton, + MatFormField, + MatLabel, + MatError, + MatInput, + ContextErrorBanner, + NifiSpinnerDirective + ], + templateUrl: './create-branch-dialog.component.html', + styleUrl: './create-branch-dialog.component.scss' +}) +export class CreateBranchDialog extends CloseOnEscapeDialog { + private dialogRequest = inject(MAT_DIALOG_DATA); + private formBuilder = inject(FormBuilder); + private destroyRef = inject(DestroyRef); + + @Output() createBranch: EventEmitter = new EventEmitter(); + + @Input({ required: true }) saving!: Signal; + + protected readonly ErrorContextKey = ErrorContextKey; + + currentBranch = this.dialogRequest.versionControlInformation.branch; + + createBranchForm: FormGroup; + + constructor() { + super(); + this.createBranchForm = this.formBuilder.group({ + branch: [ + '', + [Validators.required, Validators.pattern(/^(?!\s).*$/), this.branchNotCurrentValidator.bind(this)] + ] + }); + + const branchControl = this.createBranchForm.controls['branch']; + branchControl.valueChanges.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(() => { + if (branchControl.hasError('branchConflicts')) { + branchControl.markAsTouched({ onlySelf: true }); + } + }); + } + + submitForm(): void { + if (this.createBranchForm.invalid) { + this.createBranchForm.markAllAsTouched(); + return; + } + + this.createBranch.emit(this.createBranchForm.controls['branch'].value.trim()); + } + + private branchNotCurrentValidator(control: AbstractControl): ValidationErrors | null { + const value = control.value; + if (!value) { + return null; + } + + if (this.currentBranch && value.trim() === this.currentBranch) { + return { branchConflicts: true }; + } + + return null; + } +} \ 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..54de58554746 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_BRANCH = 'flow-branch', FUNNEL = 'funnel', LOCAL_EXTENSIONS = 'local-extensions', LINEAGE = 'lineage',