mcgilman commented on code in PR #8965:
URL: https://github.com/apache/nifi/pull/8965#discussion_r1811538388


##########
nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/move-controller-service/move-controller-service.component.html:
##########
@@ -0,0 +1,80 @@
+<!--
+  ~ 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.
+  -->
+
+<h2 mat-dialog-title>Move Controller Service</h2>
+<form class="controller-service-move-form" 
[formGroup]="moveControllerServiceForm">
+    <mat-dialog-content>
+        <div class="py-4 flex gap-x-3">
+            <div class="flex basis-1/2 flex-col gap-y-4 pr-4 overflow-hidden">
+                <div>
+                    <div>Service</div>
+                    <div class="tertiary-color font-medium truncate" 
[title]="controllerService.component.name">
+                        {{ controllerService.component.name }}
+                    </div>
+                </div>
+                <div>
+                    <mat-form-field>
+                        <mat-label>Process Group:</mat-label>
+                        <mat-select formControlName="processGroups">
+                            @for (option of 
controllerServiceActionProcessGroups; track option) {
+                                <div
+                                    nifiTooltip
+                                    [tooltipDisabled]="!option.disabled"
+                                    [tooltipComponentType]="TextTip"
+                                    [tooltipInputData]="option.description"
+                                    [delayClose]="false">
+                                    <mat-option [value]="option.value" 
[disabled]="option.disabled">
+                                        <div
+                                            *ngIf="option.description != 
undefined; else valid"
+                                            class="pointer fa fa-warning 
has-errors caution-color"
+                                            style="padding: 3px"></div>
+                                        <ng-template #valid>
+                                            <div nifiTooltip 
[delayClose]="false"></div>
+                                        </ng-template>
+
+                                        {{ option.text }}
+                                    </mat-option>
+                                </div>
+                            }
+                        </mat-select>
+                    </mat-form-field>
+                </div>
+            </div>
+            <div class="flex basis-1/2 flex-col">
+                <div>
+                    Referencing Components
+                    <i
+                        class="fa fa-info-circle"
+                        nifiTooltip
+                        [tooltipComponentType]="TextTip"
+                        tooltipInputData="Other components referencing this 
controller service."></i>
+                </div>
+                <div class="relative h-full border" style="min-height: 320px">
+                    <div class="absolute inset-0 overflow-y-auto p-1">
+                        <controller-service-references
+                            
[serviceReferences]="controllerService.component.referencingComponents"
+                            
[goToReferencingComponent]="goToReferencingComponent"></controller-service-references>
+                    </div>
+                </div>
+            </div>
+        </div>
+    </mat-dialog-content>
+    <mat-dialog-actions align="end">
+        <button mat-button mat-dialog-close>Cancel</button>
+        <button type="button" color="primary" (click)="submitForm()" 
[disabled]="disableSubmit" mat-button>Move</button>

Review Comment:
   Can you use the state of the form instead of introducing a new field? I 
didn't try this out but maybe something like the following:
   
   ```suggestion
           <button type="button" color="primary" (click)="submitForm()" 
[disabled]="moveControllerServiceForm.invalid" mat-button>Move</button>
   ```



##########
nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/move-controller-service/move-controller-service.component.ts:
##########
@@ -0,0 +1,247 @@
+/*
+ * 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, Inject, Input } from '@angular/core';
+import { MAT_DIALOG_DATA, MatDialogModule } from '@angular/material/dialog';
+import { FormBuilder, FormControl, FormGroup, ReactiveFormsModule, Validators 
} from '@angular/forms';
+import {
+    ControllerServiceEntity,
+    ControllerServiceReferencingComponent,
+    ControllerServiceReferencingComponentEntity
+} from '../../../../state/shared';
+import { MatInputModule } from '@angular/material/input';
+import { MatCheckboxModule } from '@angular/material/checkbox';
+import { MatButtonModule } from '@angular/material/button';
+import { AsyncPipe, NgTemplateOutlet } from '@angular/common';
+import { MatTabsModule } from '@angular/material/tabs';
+import { MatOptionModule } from '@angular/material/core';
+import { MatSelectModule } from '@angular/material/select';
+import { ControllerServiceApi } from 
'../../../../ui/common/controller-service/controller-service-api/controller-service-api.component';
+import { ControllerServiceReferences } from 
'../../../../ui/common/controller-service/controller-service-references/controller-service-references.component';
+import { NifiSpinnerDirective } from 
'../../../../ui/common/spinner/nifi-spinner.directive';
+import { TextTip, NifiTooltipDirective, SelectOption } from '@nifi/shared';
+import { Store } from '@ngrx/store';
+import { CloseOnEscapeDialog } from '@nifi/shared';
+import { moveControllerService } from 
'../../state/controller-services/controller-services.actions';
+import { NiFiState } from 'apps/nifi/src/app/state';
+import { MoveControllerServiceDialogRequestSuccess } from 
'../../state/controller-services';
+import { NgIf } from '@angular/common';
+import { BreadcrumbEntity } from '../../state/shared';
+
+@Component({
+    selector: 'move-controller-service',
+    standalone: true,
+    templateUrl: './move-controller-service.component.html',
+    imports: [
+        ReactiveFormsModule,
+        MatDialogModule,
+        MatInputModule,
+        MatCheckboxModule,
+        MatButtonModule,
+        MatTabsModule,
+        MatOptionModule,
+        MatSelectModule,
+        ControllerServiceApi,
+        ControllerServiceReferences,
+        AsyncPipe,
+        NifiSpinnerDirective,
+        NifiTooltipDirective,
+        NgTemplateOutlet,
+        NgIf
+    ],
+    styleUrls: ['./move-controller-service.component.scss']
+})
+export class MoveControllerService extends CloseOnEscapeDialog {
+    @Input() goToReferencingComponent!: (component: 
ControllerServiceReferencingComponent) => void;
+    protected readonly TextTip = TextTip;
+    protected controllerServiceActionProcessGroups: SelectOption[] = [];
+    protected disableSubmit: boolean = false;
+
+    controllerService: ControllerServiceEntity;
+
+    moveControllerServiceForm: FormGroup;
+
+    constructor(
+        @Inject(MAT_DIALOG_DATA) public request: 
MoveControllerServiceDialogRequestSuccess,
+        private store: Store<NiFiState>,
+        private formBuilder: FormBuilder
+    ) {
+        super();
+
+        this.controllerService = request.controllerService;
+
+        // build the form
+        this.moveControllerServiceForm = this.formBuilder.group({
+            processGroups: new FormControl('Process Group', 
Validators.required)

Review Comment:
   `Process Group` is not a valid value for this field. We could initialize the 
value to `null` but we're setting the value below. We could instead determine 
the value before creating the form and then just use the correct value when 
creating the `FormControl`.



##########
nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/move-controller-service/move-controller-service.component.ts:
##########
@@ -0,0 +1,247 @@
+/*
+ * 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, Inject, Input } from '@angular/core';
+import { MAT_DIALOG_DATA, MatDialogModule } from '@angular/material/dialog';
+import { FormBuilder, FormControl, FormGroup, ReactiveFormsModule, Validators 
} from '@angular/forms';
+import {
+    ControllerServiceEntity,
+    ControllerServiceReferencingComponent,
+    ControllerServiceReferencingComponentEntity
+} from '../../../../state/shared';
+import { MatInputModule } from '@angular/material/input';
+import { MatCheckboxModule } from '@angular/material/checkbox';
+import { MatButtonModule } from '@angular/material/button';
+import { AsyncPipe, NgTemplateOutlet } from '@angular/common';
+import { MatTabsModule } from '@angular/material/tabs';
+import { MatOptionModule } from '@angular/material/core';
+import { MatSelectModule } from '@angular/material/select';
+import { ControllerServiceApi } from 
'../../../../ui/common/controller-service/controller-service-api/controller-service-api.component';
+import { ControllerServiceReferences } from 
'../../../../ui/common/controller-service/controller-service-references/controller-service-references.component';
+import { NifiSpinnerDirective } from 
'../../../../ui/common/spinner/nifi-spinner.directive';
+import { TextTip, NifiTooltipDirective, SelectOption } from '@nifi/shared';
+import { Store } from '@ngrx/store';
+import { CloseOnEscapeDialog } from '@nifi/shared';
+import { moveControllerService } from 
'../../state/controller-services/controller-services.actions';
+import { NiFiState } from 'apps/nifi/src/app/state';
+import { MoveControllerServiceDialogRequestSuccess } from 
'../../state/controller-services';
+import { NgIf } from '@angular/common';
+import { BreadcrumbEntity } from '../../state/shared';
+
+@Component({
+    selector: 'move-controller-service',
+    standalone: true,
+    templateUrl: './move-controller-service.component.html',
+    imports: [
+        ReactiveFormsModule,
+        MatDialogModule,
+        MatInputModule,
+        MatCheckboxModule,
+        MatButtonModule,
+        MatTabsModule,
+        MatOptionModule,
+        MatSelectModule,
+        ControllerServiceApi,
+        ControllerServiceReferences,
+        AsyncPipe,
+        NifiSpinnerDirective,
+        NifiTooltipDirective,
+        NgTemplateOutlet,
+        NgIf
+    ],
+    styleUrls: ['./move-controller-service.component.scss']
+})
+export class MoveControllerService extends CloseOnEscapeDialog {
+    @Input() goToReferencingComponent!: (component: 
ControllerServiceReferencingComponent) => void;
+    protected readonly TextTip = TextTip;
+    protected controllerServiceActionProcessGroups: SelectOption[] = [];
+    protected disableSubmit: boolean = false;
+
+    controllerService: ControllerServiceEntity;
+
+    moveControllerServiceForm: FormGroup;
+
+    constructor(
+        @Inject(MAT_DIALOG_DATA) public request: 
MoveControllerServiceDialogRequestSuccess,
+        private store: Store<NiFiState>,
+        private formBuilder: FormBuilder
+    ) {
+        super();
+
+        this.controllerService = request.controllerService;
+
+        // build the form
+        this.moveControllerServiceForm = this.formBuilder.group({
+            processGroups: new FormControl('Process Group', 
Validators.required)
+        });
+
+        const parentControllerServices = 
request.parentControllerServices.filter(
+            (cs) => cs.parentGroupId != request.controllerService.parentGroupId
+        );
+
+        const processGroups: SelectOption[] = [];
+        this.loadParentOption(request.breadcrumb, parentControllerServices, 
processGroups);
+
+        this.loadChildOptions(request.childProcessGroupOptions, 
request.processGroupEntity, processGroups);
+        this.controllerServiceActionProcessGroups = processGroups;
+
+        const firstEnabled = processGroups.findIndex((pg) => !pg.disabled);
+        if (firstEnabled != -1) {
+            
this.moveControllerServiceForm.controls['processGroups'].setValue(processGroups[firstEnabled].value);
+        } else {
+            if (processGroups.length > 0) {
+                
this.moveControllerServiceForm.controls['processGroups'].setValue(processGroups[0].value);
+            }
+            this.disableSubmit = true;
+        }
+    }
+
+    loadParentOption(
+        breadcrumb: BreadcrumbEntity,
+        parentControllerServices: ControllerServiceEntity[],
+        processGroups: SelectOption[]
+    ) {
+        if (breadcrumb.parentBreadcrumb != undefined) {
+            const parentBreadcrumb = breadcrumb.parentBreadcrumb;
+            if (parentBreadcrumb.permissions.canRead && 
parentBreadcrumb.permissions.canWrite) {
+                const option: SelectOption = {
+                    text: parentBreadcrumb.breadcrumb.name + ' (Parent)',

Review Comment:
   Please convert to a template string.



##########
nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/move-controller-service/move-controller-service.component.ts:
##########
@@ -0,0 +1,247 @@
+/*
+ * 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, Inject, Input } from '@angular/core';
+import { MAT_DIALOG_DATA, MatDialogModule } from '@angular/material/dialog';
+import { FormBuilder, FormControl, FormGroup, ReactiveFormsModule, Validators 
} from '@angular/forms';
+import {
+    ControllerServiceEntity,
+    ControllerServiceReferencingComponent,
+    ControllerServiceReferencingComponentEntity
+} from '../../../../state/shared';
+import { MatInputModule } from '@angular/material/input';
+import { MatCheckboxModule } from '@angular/material/checkbox';
+import { MatButtonModule } from '@angular/material/button';
+import { AsyncPipe, NgTemplateOutlet } from '@angular/common';
+import { MatTabsModule } from '@angular/material/tabs';
+import { MatOptionModule } from '@angular/material/core';
+import { MatSelectModule } from '@angular/material/select';
+import { ControllerServiceApi } from 
'../../../../ui/common/controller-service/controller-service-api/controller-service-api.component';
+import { ControllerServiceReferences } from 
'../../../../ui/common/controller-service/controller-service-references/controller-service-references.component';
+import { NifiSpinnerDirective } from 
'../../../../ui/common/spinner/nifi-spinner.directive';
+import { TextTip, NifiTooltipDirective, SelectOption } from '@nifi/shared';
+import { Store } from '@ngrx/store';
+import { CloseOnEscapeDialog } from '@nifi/shared';
+import { moveControllerService } from 
'../../state/controller-services/controller-services.actions';
+import { NiFiState } from 'apps/nifi/src/app/state';
+import { MoveControllerServiceDialogRequestSuccess } from 
'../../state/controller-services';
+import { NgIf } from '@angular/common';
+import { BreadcrumbEntity } from '../../state/shared';
+
+@Component({
+    selector: 'move-controller-service',
+    standalone: true,
+    templateUrl: './move-controller-service.component.html',
+    imports: [
+        ReactiveFormsModule,
+        MatDialogModule,
+        MatInputModule,
+        MatCheckboxModule,
+        MatButtonModule,
+        MatTabsModule,
+        MatOptionModule,
+        MatSelectModule,
+        ControllerServiceApi,
+        ControllerServiceReferences,
+        AsyncPipe,
+        NifiSpinnerDirective,
+        NifiTooltipDirective,
+        NgTemplateOutlet,
+        NgIf
+    ],
+    styleUrls: ['./move-controller-service.component.scss']
+})
+export class MoveControllerService extends CloseOnEscapeDialog {
+    @Input() goToReferencingComponent!: (component: 
ControllerServiceReferencingComponent) => void;
+    protected readonly TextTip = TextTip;
+    protected controllerServiceActionProcessGroups: SelectOption[] = [];
+    protected disableSubmit: boolean = false;
+
+    controllerService: ControllerServiceEntity;
+
+    moveControllerServiceForm: FormGroup;
+
+    constructor(
+        @Inject(MAT_DIALOG_DATA) public request: 
MoveControllerServiceDialogRequestSuccess,
+        private store: Store<NiFiState>,
+        private formBuilder: FormBuilder
+    ) {
+        super();
+
+        this.controllerService = request.controllerService;
+
+        // build the form
+        this.moveControllerServiceForm = this.formBuilder.group({
+            processGroups: new FormControl('Process Group', 
Validators.required)
+        });
+
+        const parentControllerServices = 
request.parentControllerServices.filter(
+            (cs) => cs.parentGroupId != request.controllerService.parentGroupId
+        );
+
+        const processGroups: SelectOption[] = [];
+        this.loadParentOption(request.breadcrumb, parentControllerServices, 
processGroups);
+
+        this.loadChildOptions(request.childProcessGroupOptions, 
request.processGroupEntity, processGroups);
+        this.controllerServiceActionProcessGroups = processGroups;
+
+        const firstEnabled = processGroups.findIndex((pg) => !pg.disabled);
+        if (firstEnabled != -1) {
+            
this.moveControllerServiceForm.controls['processGroups'].setValue(processGroups[firstEnabled].value);
+        } else {
+            if (processGroups.length > 0) {
+                
this.moveControllerServiceForm.controls['processGroups'].setValue(processGroups[0].value);
+            }
+            this.disableSubmit = true;
+        }
+    }
+
+    loadParentOption(
+        breadcrumb: BreadcrumbEntity,
+        parentControllerServices: ControllerServiceEntity[],
+        processGroups: SelectOption[]
+    ) {
+        if (breadcrumb.parentBreadcrumb != undefined) {
+            const parentBreadcrumb = breadcrumb.parentBreadcrumb;
+            if (parentBreadcrumb.permissions.canRead && 
parentBreadcrumb.permissions.canWrite) {
+                const option: SelectOption = {
+                    text: parentBreadcrumb.breadcrumb.name + ' (Parent)',
+                    value: parentBreadcrumb.breadcrumb.id
+                };
+
+                let errorMsg = '';
+                const descriptors = 
this.controllerService.component.descriptors;
+                for (const descriptor in descriptors) {
+                    if (descriptors[descriptor].identifiesControllerService != 
undefined) {

Review Comment:
   ```suggestion
                       if (descriptors[descriptor].identifiesControllerService) 
{
   ```



##########
nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/move-controller-service/move-controller-service.component.ts:
##########
@@ -0,0 +1,247 @@
+/*
+ * 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, Inject, Input } from '@angular/core';
+import { MAT_DIALOG_DATA, MatDialogModule } from '@angular/material/dialog';
+import { FormBuilder, FormControl, FormGroup, ReactiveFormsModule, Validators 
} from '@angular/forms';
+import {
+    ControllerServiceEntity,
+    ControllerServiceReferencingComponent,
+    ControllerServiceReferencingComponentEntity
+} from '../../../../state/shared';
+import { MatInputModule } from '@angular/material/input';
+import { MatCheckboxModule } from '@angular/material/checkbox';
+import { MatButtonModule } from '@angular/material/button';
+import { AsyncPipe, NgTemplateOutlet } from '@angular/common';
+import { MatTabsModule } from '@angular/material/tabs';
+import { MatOptionModule } from '@angular/material/core';
+import { MatSelectModule } from '@angular/material/select';
+import { ControllerServiceApi } from 
'../../../../ui/common/controller-service/controller-service-api/controller-service-api.component';
+import { ControllerServiceReferences } from 
'../../../../ui/common/controller-service/controller-service-references/controller-service-references.component';
+import { NifiSpinnerDirective } from 
'../../../../ui/common/spinner/nifi-spinner.directive';
+import { TextTip, NifiTooltipDirective, SelectOption } from '@nifi/shared';
+import { Store } from '@ngrx/store';
+import { CloseOnEscapeDialog } from '@nifi/shared';
+import { moveControllerService } from 
'../../state/controller-services/controller-services.actions';
+import { NiFiState } from 'apps/nifi/src/app/state';
+import { MoveControllerServiceDialogRequestSuccess } from 
'../../state/controller-services';
+import { NgIf } from '@angular/common';
+import { BreadcrumbEntity } from '../../state/shared';
+
+@Component({
+    selector: 'move-controller-service',
+    standalone: true,
+    templateUrl: './move-controller-service.component.html',
+    imports: [
+        ReactiveFormsModule,
+        MatDialogModule,
+        MatInputModule,
+        MatCheckboxModule,
+        MatButtonModule,
+        MatTabsModule,
+        MatOptionModule,
+        MatSelectModule,
+        ControllerServiceApi,
+        ControllerServiceReferences,
+        AsyncPipe,
+        NifiSpinnerDirective,
+        NifiTooltipDirective,
+        NgTemplateOutlet,
+        NgIf
+    ],
+    styleUrls: ['./move-controller-service.component.scss']
+})
+export class MoveControllerService extends CloseOnEscapeDialog {
+    @Input() goToReferencingComponent!: (component: 
ControllerServiceReferencingComponent) => void;
+    protected readonly TextTip = TextTip;
+    protected controllerServiceActionProcessGroups: SelectOption[] = [];
+    protected disableSubmit: boolean = false;
+
+    controllerService: ControllerServiceEntity;
+
+    moveControllerServiceForm: FormGroup;
+
+    constructor(
+        @Inject(MAT_DIALOG_DATA) public request: 
MoveControllerServiceDialogRequestSuccess,
+        private store: Store<NiFiState>,
+        private formBuilder: FormBuilder
+    ) {
+        super();
+
+        this.controllerService = request.controllerService;
+
+        // build the form
+        this.moveControllerServiceForm = this.formBuilder.group({
+            processGroups: new FormControl('Process Group', 
Validators.required)
+        });
+
+        const parentControllerServices = 
request.parentControllerServices.filter(
+            (cs) => cs.parentGroupId != request.controllerService.parentGroupId
+        );
+
+        const processGroups: SelectOption[] = [];
+        this.loadParentOption(request.breadcrumb, parentControllerServices, 
processGroups);
+
+        this.loadChildOptions(request.childProcessGroupOptions, 
request.processGroupEntity, processGroups);
+        this.controllerServiceActionProcessGroups = processGroups;
+
+        const firstEnabled = processGroups.findIndex((pg) => !pg.disabled);
+        if (firstEnabled != -1) {
+            
this.moveControllerServiceForm.controls['processGroups'].setValue(processGroups[firstEnabled].value);
+        } else {
+            if (processGroups.length > 0) {
+                
this.moveControllerServiceForm.controls['processGroups'].setValue(processGroups[0].value);
+            }
+            this.disableSubmit = true;
+        }
+    }
+
+    loadParentOption(
+        breadcrumb: BreadcrumbEntity,
+        parentControllerServices: ControllerServiceEntity[],
+        processGroups: SelectOption[]
+    ) {
+        if (breadcrumb.parentBreadcrumb != undefined) {
+            const parentBreadcrumb = breadcrumb.parentBreadcrumb;
+            if (parentBreadcrumb.permissions.canRead && 
parentBreadcrumb.permissions.canWrite) {
+                const option: SelectOption = {
+                    text: parentBreadcrumb.breadcrumb.name + ' (Parent)',
+                    value: parentBreadcrumb.breadcrumb.id
+                };
+
+                let errorMsg = '';
+                const descriptors = 
this.controllerService.component.descriptors;
+                for (const descriptor in descriptors) {
+                    if (descriptors[descriptor].identifiesControllerService != 
undefined) {
+                        const controllerId = 
this.controllerService.component.properties[descriptors[descriptor].name];
+                        if (
+                            controllerId != null &&
+                            !parentControllerServices.some((service) => 
service.id == controllerId)
+                        ) {
+                            errorMsg += '[' + descriptors[descriptor].name + 
']';
+                        }
+                    }
+                }
+
+                if (errorMsg != '') {
+                    option.description =
+                        'The following properties reference controller 
services that would be out of scope for this ' +
+                        'process group: ' +
+                        errorMsg;
+                    option.disabled = true;
+                } else {
+                    option.disabled = false;
+                }
+
+                processGroups.push(option);
+            }
+        }
+    }
+
+    loadChildOptions(
+        childProcessGroupOptions: SelectOption[],
+        currentProcessGroupEntity: any,
+        processGroups: SelectOption[]
+    ) {
+        const referencingComponents: 
ControllerServiceReferencingComponentEntity[] =
+            this.controllerService.component.referencingComponents;
+        childProcessGroupOptions.forEach((child: SelectOption) => {
+            const option: SelectOption = {
+                text: child.text,
+                value: child.value
+            };
+
+            const root = 
this.getProcessGroupById(currentProcessGroupEntity.component, child.value ?? 
'');
+            let errorMsg = '';
+
+            if (root == null) {
+                option.description = 'Error loading process group root.';
+                option.disabled = true;
+                processGroups.push(option);
+            } else {
+                for (const component of referencingComponents) {
+                    if (!this.processGroupContainsComponent(root, 
component.component.groupId)) {
+                        errorMsg += '[' + component.component.name + ']';
+                    }
+                }
+
+                if (errorMsg != '') {
+                    option.description =
+                        'The following components would be out of scope for 
this process group: ' + errorMsg;
+                    option.disabled = true;
+                } else {
+                    option.disabled = false;
+                }
+
+                processGroups.push(option);
+            }
+        });
+    }
+
+    processGroupContainsComponent(processGroup: any, groupId: string): boolean 
{
+        if (processGroup.contents != undefined) {

Review Comment:
   ```suggestion
           if (processGroup.contents) {
   ```



##########
nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/move-controller-service/move-controller-service.component.ts:
##########
@@ -0,0 +1,247 @@
+/*
+ * 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, Inject, Input } from '@angular/core';
+import { MAT_DIALOG_DATA, MatDialogModule } from '@angular/material/dialog';
+import { FormBuilder, FormControl, FormGroup, ReactiveFormsModule, Validators 
} from '@angular/forms';
+import {
+    ControllerServiceEntity,
+    ControllerServiceReferencingComponent,
+    ControllerServiceReferencingComponentEntity
+} from '../../../../state/shared';
+import { MatInputModule } from '@angular/material/input';
+import { MatCheckboxModule } from '@angular/material/checkbox';
+import { MatButtonModule } from '@angular/material/button';
+import { AsyncPipe, NgTemplateOutlet } from '@angular/common';
+import { MatTabsModule } from '@angular/material/tabs';
+import { MatOptionModule } from '@angular/material/core';
+import { MatSelectModule } from '@angular/material/select';
+import { ControllerServiceApi } from 
'../../../../ui/common/controller-service/controller-service-api/controller-service-api.component';
+import { ControllerServiceReferences } from 
'../../../../ui/common/controller-service/controller-service-references/controller-service-references.component';
+import { NifiSpinnerDirective } from 
'../../../../ui/common/spinner/nifi-spinner.directive';
+import { TextTip, NifiTooltipDirective, SelectOption } from '@nifi/shared';
+import { Store } from '@ngrx/store';
+import { CloseOnEscapeDialog } from '@nifi/shared';
+import { moveControllerService } from 
'../../state/controller-services/controller-services.actions';
+import { NiFiState } from 'apps/nifi/src/app/state';
+import { MoveControllerServiceDialogRequestSuccess } from 
'../../state/controller-services';
+import { NgIf } from '@angular/common';
+import { BreadcrumbEntity } from '../../state/shared';
+
+@Component({
+    selector: 'move-controller-service',
+    standalone: true,
+    templateUrl: './move-controller-service.component.html',
+    imports: [
+        ReactiveFormsModule,
+        MatDialogModule,
+        MatInputModule,
+        MatCheckboxModule,
+        MatButtonModule,
+        MatTabsModule,
+        MatOptionModule,
+        MatSelectModule,
+        ControllerServiceApi,
+        ControllerServiceReferences,
+        AsyncPipe,
+        NifiSpinnerDirective,
+        NifiTooltipDirective,
+        NgTemplateOutlet,
+        NgIf
+    ],
+    styleUrls: ['./move-controller-service.component.scss']
+})
+export class MoveControllerService extends CloseOnEscapeDialog {
+    @Input() goToReferencingComponent!: (component: 
ControllerServiceReferencingComponent) => void;
+    protected readonly TextTip = TextTip;
+    protected controllerServiceActionProcessGroups: SelectOption[] = [];
+    protected disableSubmit: boolean = false;
+
+    controllerService: ControllerServiceEntity;
+
+    moveControllerServiceForm: FormGroup;
+
+    constructor(
+        @Inject(MAT_DIALOG_DATA) public request: 
MoveControllerServiceDialogRequestSuccess,
+        private store: Store<NiFiState>,
+        private formBuilder: FormBuilder
+    ) {
+        super();
+
+        this.controllerService = request.controllerService;
+
+        // build the form
+        this.moveControllerServiceForm = this.formBuilder.group({
+            processGroups: new FormControl('Process Group', 
Validators.required)
+        });
+
+        const parentControllerServices = 
request.parentControllerServices.filter(
+            (cs) => cs.parentGroupId != request.controllerService.parentGroupId
+        );
+
+        const processGroups: SelectOption[] = [];
+        this.loadParentOption(request.breadcrumb, parentControllerServices, 
processGroups);
+
+        this.loadChildOptions(request.childProcessGroupOptions, 
request.processGroupEntity, processGroups);
+        this.controllerServiceActionProcessGroups = processGroups;
+
+        const firstEnabled = processGroups.findIndex((pg) => !pg.disabled);
+        if (firstEnabled != -1) {
+            
this.moveControllerServiceForm.controls['processGroups'].setValue(processGroups[firstEnabled].value);
+        } else {
+            if (processGroups.length > 0) {
+                
this.moveControllerServiceForm.controls['processGroups'].setValue(processGroups[0].value);
+            }
+            this.disableSubmit = true;
+        }
+    }
+
+    loadParentOption(
+        breadcrumb: BreadcrumbEntity,
+        parentControllerServices: ControllerServiceEntity[],
+        processGroups: SelectOption[]
+    ) {
+        if (breadcrumb.parentBreadcrumb != undefined) {
+            const parentBreadcrumb = breadcrumb.parentBreadcrumb;
+            if (parentBreadcrumb.permissions.canRead && 
parentBreadcrumb.permissions.canWrite) {
+                const option: SelectOption = {
+                    text: parentBreadcrumb.breadcrumb.name + ' (Parent)',
+                    value: parentBreadcrumb.breadcrumb.id
+                };
+
+                let errorMsg = '';
+                const descriptors = 
this.controllerService.component.descriptors;
+                for (const descriptor in descriptors) {
+                    if (descriptors[descriptor].identifiesControllerService != 
undefined) {
+                        const controllerId = 
this.controllerService.component.properties[descriptors[descriptor].name];
+                        if (
+                            controllerId != null &&
+                            !parentControllerServices.some((service) => 
service.id == controllerId)
+                        ) {
+                            errorMsg += '[' + descriptors[descriptor].name + 
']';
+                        }
+                    }
+                }
+
+                if (errorMsg != '') {
+                    option.description =
+                        'The following properties reference controller 
services that would be out of scope for this ' +
+                        'process group: ' +
+                        errorMsg;

Review Comment:
   Please convert to a template string.



##########
nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/move-controller-service/move-controller-service.component.ts:
##########
@@ -0,0 +1,247 @@
+/*
+ * 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, Inject, Input } from '@angular/core';
+import { MAT_DIALOG_DATA, MatDialogModule } from '@angular/material/dialog';
+import { FormBuilder, FormControl, FormGroup, ReactiveFormsModule, Validators 
} from '@angular/forms';
+import {
+    ControllerServiceEntity,
+    ControllerServiceReferencingComponent,
+    ControllerServiceReferencingComponentEntity
+} from '../../../../state/shared';
+import { MatInputModule } from '@angular/material/input';
+import { MatCheckboxModule } from '@angular/material/checkbox';
+import { MatButtonModule } from '@angular/material/button';
+import { AsyncPipe, NgTemplateOutlet } from '@angular/common';
+import { MatTabsModule } from '@angular/material/tabs';
+import { MatOptionModule } from '@angular/material/core';
+import { MatSelectModule } from '@angular/material/select';
+import { ControllerServiceApi } from 
'../../../../ui/common/controller-service/controller-service-api/controller-service-api.component';
+import { ControllerServiceReferences } from 
'../../../../ui/common/controller-service/controller-service-references/controller-service-references.component';
+import { NifiSpinnerDirective } from 
'../../../../ui/common/spinner/nifi-spinner.directive';
+import { TextTip, NifiTooltipDirective, SelectOption } from '@nifi/shared';
+import { Store } from '@ngrx/store';
+import { CloseOnEscapeDialog } from '@nifi/shared';
+import { moveControllerService } from 
'../../state/controller-services/controller-services.actions';
+import { NiFiState } from 'apps/nifi/src/app/state';
+import { MoveControllerServiceDialogRequestSuccess } from 
'../../state/controller-services';
+import { NgIf } from '@angular/common';
+import { BreadcrumbEntity } from '../../state/shared';
+
+@Component({
+    selector: 'move-controller-service',
+    standalone: true,
+    templateUrl: './move-controller-service.component.html',
+    imports: [
+        ReactiveFormsModule,
+        MatDialogModule,
+        MatInputModule,
+        MatCheckboxModule,
+        MatButtonModule,
+        MatTabsModule,
+        MatOptionModule,
+        MatSelectModule,
+        ControllerServiceApi,
+        ControllerServiceReferences,
+        AsyncPipe,
+        NifiSpinnerDirective,
+        NifiTooltipDirective,
+        NgTemplateOutlet,
+        NgIf
+    ],
+    styleUrls: ['./move-controller-service.component.scss']
+})
+export class MoveControllerService extends CloseOnEscapeDialog {

Review Comment:
   Please move the `move-controller-service` folder under 
`src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/controller-service`.
 



##########
nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/move-controller-service/move-controller-service.component.ts:
##########
@@ -0,0 +1,247 @@
+/*
+ * 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, Inject, Input } from '@angular/core';
+import { MAT_DIALOG_DATA, MatDialogModule } from '@angular/material/dialog';
+import { FormBuilder, FormControl, FormGroup, ReactiveFormsModule, Validators 
} from '@angular/forms';
+import {
+    ControllerServiceEntity,
+    ControllerServiceReferencingComponent,
+    ControllerServiceReferencingComponentEntity
+} from '../../../../state/shared';
+import { MatInputModule } from '@angular/material/input';
+import { MatCheckboxModule } from '@angular/material/checkbox';
+import { MatButtonModule } from '@angular/material/button';
+import { AsyncPipe, NgTemplateOutlet } from '@angular/common';
+import { MatTabsModule } from '@angular/material/tabs';
+import { MatOptionModule } from '@angular/material/core';
+import { MatSelectModule } from '@angular/material/select';
+import { ControllerServiceApi } from 
'../../../../ui/common/controller-service/controller-service-api/controller-service-api.component';
+import { ControllerServiceReferences } from 
'../../../../ui/common/controller-service/controller-service-references/controller-service-references.component';
+import { NifiSpinnerDirective } from 
'../../../../ui/common/spinner/nifi-spinner.directive';
+import { TextTip, NifiTooltipDirective, SelectOption } from '@nifi/shared';
+import { Store } from '@ngrx/store';
+import { CloseOnEscapeDialog } from '@nifi/shared';
+import { moveControllerService } from 
'../../state/controller-services/controller-services.actions';
+import { NiFiState } from 'apps/nifi/src/app/state';
+import { MoveControllerServiceDialogRequestSuccess } from 
'../../state/controller-services';
+import { NgIf } from '@angular/common';
+import { BreadcrumbEntity } from '../../state/shared';
+
+@Component({
+    selector: 'move-controller-service',
+    standalone: true,
+    templateUrl: './move-controller-service.component.html',
+    imports: [
+        ReactiveFormsModule,
+        MatDialogModule,
+        MatInputModule,
+        MatCheckboxModule,
+        MatButtonModule,
+        MatTabsModule,
+        MatOptionModule,
+        MatSelectModule,
+        ControllerServiceApi,
+        ControllerServiceReferences,
+        AsyncPipe,
+        NifiSpinnerDirective,
+        NifiTooltipDirective,
+        NgTemplateOutlet,
+        NgIf
+    ],
+    styleUrls: ['./move-controller-service.component.scss']
+})
+export class MoveControllerService extends CloseOnEscapeDialog {
+    @Input() goToReferencingComponent!: (component: 
ControllerServiceReferencingComponent) => void;
+    protected readonly TextTip = TextTip;
+    protected controllerServiceActionProcessGroups: SelectOption[] = [];
+    protected disableSubmit: boolean = false;
+
+    controllerService: ControllerServiceEntity;
+
+    moveControllerServiceForm: FormGroup;
+
+    constructor(
+        @Inject(MAT_DIALOG_DATA) public request: 
MoveControllerServiceDialogRequestSuccess,
+        private store: Store<NiFiState>,
+        private formBuilder: FormBuilder
+    ) {
+        super();
+
+        this.controllerService = request.controllerService;
+
+        // build the form
+        this.moveControllerServiceForm = this.formBuilder.group({
+            processGroups: new FormControl('Process Group', 
Validators.required)
+        });
+
+        const parentControllerServices = 
request.parentControllerServices.filter(
+            (cs) => cs.parentGroupId != request.controllerService.parentGroupId
+        );
+
+        const processGroups: SelectOption[] = [];
+        this.loadParentOption(request.breadcrumb, parentControllerServices, 
processGroups);
+
+        this.loadChildOptions(request.childProcessGroupOptions, 
request.processGroupEntity, processGroups);
+        this.controllerServiceActionProcessGroups = processGroups;
+
+        const firstEnabled = processGroups.findIndex((pg) => !pg.disabled);
+        if (firstEnabled != -1) {
+            
this.moveControllerServiceForm.controls['processGroups'].setValue(processGroups[firstEnabled].value);
+        } else {
+            if (processGroups.length > 0) {
+                
this.moveControllerServiceForm.controls['processGroups'].setValue(processGroups[0].value);
+            }
+            this.disableSubmit = true;
+        }
+    }
+
+    loadParentOption(
+        breadcrumb: BreadcrumbEntity,
+        parentControllerServices: ControllerServiceEntity[],
+        processGroups: SelectOption[]
+    ) {
+        if (breadcrumb.parentBreadcrumb != undefined) {
+            const parentBreadcrumb = breadcrumb.parentBreadcrumb;
+            if (parentBreadcrumb.permissions.canRead && 
parentBreadcrumb.permissions.canWrite) {
+                const option: SelectOption = {
+                    text: parentBreadcrumb.breadcrumb.name + ' (Parent)',
+                    value: parentBreadcrumb.breadcrumb.id
+                };
+
+                let errorMsg = '';
+                const descriptors = 
this.controllerService.component.descriptors;
+                for (const descriptor in descriptors) {
+                    if (descriptors[descriptor].identifiesControllerService != 
undefined) {
+                        const controllerId = 
this.controllerService.component.properties[descriptors[descriptor].name];
+                        if (
+                            controllerId != null &&
+                            !parentControllerServices.some((service) => 
service.id == controllerId)
+                        ) {
+                            errorMsg += '[' + descriptors[descriptor].name + 
']';
+                        }
+                    }
+                }
+
+                if (errorMsg != '') {
+                    option.description =
+                        'The following properties reference controller 
services that would be out of scope for this ' +
+                        'process group: ' +
+                        errorMsg;
+                    option.disabled = true;
+                } else {
+                    option.disabled = false;
+                }
+
+                processGroups.push(option);
+            }
+        }
+    }
+
+    loadChildOptions(
+        childProcessGroupOptions: SelectOption[],
+        currentProcessGroupEntity: any,
+        processGroups: SelectOption[]
+    ) {
+        const referencingComponents: 
ControllerServiceReferencingComponentEntity[] =
+            this.controllerService.component.referencingComponents;
+        childProcessGroupOptions.forEach((child: SelectOption) => {
+            const option: SelectOption = {
+                text: child.text,
+                value: child.value
+            };
+
+            const root = 
this.getProcessGroupById(currentProcessGroupEntity.component, child.value ?? 
'');
+            let errorMsg = '';
+
+            if (root == null) {
+                option.description = 'Error loading process group root.';
+                option.disabled = true;
+                processGroups.push(option);
+            } else {
+                for (const component of referencingComponents) {
+                    if (!this.processGroupContainsComponent(root, 
component.component.groupId)) {
+                        errorMsg += '[' + component.component.name + ']';

Review Comment:
   Please convert to a template string if possible.



##########
nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/move-controller-service/move-controller-service.component.ts:
##########
@@ -0,0 +1,247 @@
+/*
+ * 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, Inject, Input } from '@angular/core';
+import { MAT_DIALOG_DATA, MatDialogModule } from '@angular/material/dialog';
+import { FormBuilder, FormControl, FormGroup, ReactiveFormsModule, Validators 
} from '@angular/forms';
+import {
+    ControllerServiceEntity,
+    ControllerServiceReferencingComponent,
+    ControllerServiceReferencingComponentEntity
+} from '../../../../state/shared';
+import { MatInputModule } from '@angular/material/input';
+import { MatCheckboxModule } from '@angular/material/checkbox';
+import { MatButtonModule } from '@angular/material/button';
+import { AsyncPipe, NgTemplateOutlet } from '@angular/common';
+import { MatTabsModule } from '@angular/material/tabs';
+import { MatOptionModule } from '@angular/material/core';
+import { MatSelectModule } from '@angular/material/select';
+import { ControllerServiceApi } from 
'../../../../ui/common/controller-service/controller-service-api/controller-service-api.component';
+import { ControllerServiceReferences } from 
'../../../../ui/common/controller-service/controller-service-references/controller-service-references.component';
+import { NifiSpinnerDirective } from 
'../../../../ui/common/spinner/nifi-spinner.directive';
+import { TextTip, NifiTooltipDirective, SelectOption } from '@nifi/shared';
+import { Store } from '@ngrx/store';
+import { CloseOnEscapeDialog } from '@nifi/shared';
+import { moveControllerService } from 
'../../state/controller-services/controller-services.actions';
+import { NiFiState } from 'apps/nifi/src/app/state';
+import { MoveControllerServiceDialogRequestSuccess } from 
'../../state/controller-services';
+import { NgIf } from '@angular/common';
+import { BreadcrumbEntity } from '../../state/shared';
+
+@Component({
+    selector: 'move-controller-service',
+    standalone: true,
+    templateUrl: './move-controller-service.component.html',
+    imports: [
+        ReactiveFormsModule,
+        MatDialogModule,
+        MatInputModule,
+        MatCheckboxModule,
+        MatButtonModule,
+        MatTabsModule,
+        MatOptionModule,
+        MatSelectModule,
+        ControllerServiceApi,
+        ControllerServiceReferences,
+        AsyncPipe,
+        NifiSpinnerDirective,
+        NifiTooltipDirective,
+        NgTemplateOutlet,
+        NgIf
+    ],
+    styleUrls: ['./move-controller-service.component.scss']
+})
+export class MoveControllerService extends CloseOnEscapeDialog {
+    @Input() goToReferencingComponent!: (component: 
ControllerServiceReferencingComponent) => void;
+    protected readonly TextTip = TextTip;
+    protected controllerServiceActionProcessGroups: SelectOption[] = [];
+    protected disableSubmit: boolean = false;
+
+    controllerService: ControllerServiceEntity;
+
+    moveControllerServiceForm: FormGroup;
+
+    constructor(
+        @Inject(MAT_DIALOG_DATA) public request: 
MoveControllerServiceDialogRequestSuccess,
+        private store: Store<NiFiState>,
+        private formBuilder: FormBuilder
+    ) {
+        super();
+
+        this.controllerService = request.controllerService;
+
+        // build the form
+        this.moveControllerServiceForm = this.formBuilder.group({
+            processGroups: new FormControl('Process Group', 
Validators.required)
+        });
+
+        const parentControllerServices = 
request.parentControllerServices.filter(
+            (cs) => cs.parentGroupId != request.controllerService.parentGroupId
+        );
+
+        const processGroups: SelectOption[] = [];
+        this.loadParentOption(request.breadcrumb, parentControllerServices, 
processGroups);
+
+        this.loadChildOptions(request.childProcessGroupOptions, 
request.processGroupEntity, processGroups);
+        this.controllerServiceActionProcessGroups = processGroups;
+
+        const firstEnabled = processGroups.findIndex((pg) => !pg.disabled);
+        if (firstEnabled != -1) {
+            
this.moveControllerServiceForm.controls['processGroups'].setValue(processGroups[firstEnabled].value);
+        } else {
+            if (processGroups.length > 0) {
+                
this.moveControllerServiceForm.controls['processGroups'].setValue(processGroups[0].value);
+            }
+            this.disableSubmit = true;
+        }
+    }
+
+    loadParentOption(
+        breadcrumb: BreadcrumbEntity,
+        parentControllerServices: ControllerServiceEntity[],
+        processGroups: SelectOption[]
+    ) {
+        if (breadcrumb.parentBreadcrumb != undefined) {
+            const parentBreadcrumb = breadcrumb.parentBreadcrumb;
+            if (parentBreadcrumb.permissions.canRead && 
parentBreadcrumb.permissions.canWrite) {
+                const option: SelectOption = {
+                    text: parentBreadcrumb.breadcrumb.name + ' (Parent)',
+                    value: parentBreadcrumb.breadcrumb.id
+                };
+
+                let errorMsg = '';
+                const descriptors = 
this.controllerService.component.descriptors;
+                for (const descriptor in descriptors) {
+                    if (descriptors[descriptor].identifiesControllerService != 
undefined) {
+                        const controllerId = 
this.controllerService.component.properties[descriptors[descriptor].name];
+                        if (
+                            controllerId != null &&
+                            !parentControllerServices.some((service) => 
service.id == controllerId)
+                        ) {
+                            errorMsg += '[' + descriptors[descriptor].name + 
']';

Review Comment:
   Please convert to a template string if possible.



##########
nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/move-controller-service/move-controller-service.component.ts:
##########
@@ -0,0 +1,247 @@
+/*
+ * 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, Inject, Input } from '@angular/core';
+import { MAT_DIALOG_DATA, MatDialogModule } from '@angular/material/dialog';
+import { FormBuilder, FormControl, FormGroup, ReactiveFormsModule, Validators 
} from '@angular/forms';
+import {
+    ControllerServiceEntity,
+    ControllerServiceReferencingComponent,
+    ControllerServiceReferencingComponentEntity
+} from '../../../../state/shared';
+import { MatInputModule } from '@angular/material/input';
+import { MatCheckboxModule } from '@angular/material/checkbox';
+import { MatButtonModule } from '@angular/material/button';
+import { AsyncPipe, NgTemplateOutlet } from '@angular/common';
+import { MatTabsModule } from '@angular/material/tabs';
+import { MatOptionModule } from '@angular/material/core';
+import { MatSelectModule } from '@angular/material/select';
+import { ControllerServiceApi } from 
'../../../../ui/common/controller-service/controller-service-api/controller-service-api.component';
+import { ControllerServiceReferences } from 
'../../../../ui/common/controller-service/controller-service-references/controller-service-references.component';
+import { NifiSpinnerDirective } from 
'../../../../ui/common/spinner/nifi-spinner.directive';
+import { TextTip, NifiTooltipDirective, SelectOption } from '@nifi/shared';
+import { Store } from '@ngrx/store';
+import { CloseOnEscapeDialog } from '@nifi/shared';
+import { moveControllerService } from 
'../../state/controller-services/controller-services.actions';
+import { NiFiState } from 'apps/nifi/src/app/state';
+import { MoveControllerServiceDialogRequestSuccess } from 
'../../state/controller-services';
+import { NgIf } from '@angular/common';
+import { BreadcrumbEntity } from '../../state/shared';
+
+@Component({
+    selector: 'move-controller-service',
+    standalone: true,
+    templateUrl: './move-controller-service.component.html',
+    imports: [
+        ReactiveFormsModule,
+        MatDialogModule,
+        MatInputModule,
+        MatCheckboxModule,
+        MatButtonModule,
+        MatTabsModule,
+        MatOptionModule,
+        MatSelectModule,
+        ControllerServiceApi,
+        ControllerServiceReferences,
+        AsyncPipe,
+        NifiSpinnerDirective,
+        NifiTooltipDirective,
+        NgTemplateOutlet,
+        NgIf
+    ],
+    styleUrls: ['./move-controller-service.component.scss']
+})
+export class MoveControllerService extends CloseOnEscapeDialog {
+    @Input() goToReferencingComponent!: (component: 
ControllerServiceReferencingComponent) => void;
+    protected readonly TextTip = TextTip;
+    protected controllerServiceActionProcessGroups: SelectOption[] = [];
+    protected disableSubmit: boolean = false;
+
+    controllerService: ControllerServiceEntity;
+
+    moveControllerServiceForm: FormGroup;
+
+    constructor(
+        @Inject(MAT_DIALOG_DATA) public request: 
MoveControllerServiceDialogRequestSuccess,
+        private store: Store<NiFiState>,
+        private formBuilder: FormBuilder
+    ) {
+        super();
+
+        this.controllerService = request.controllerService;
+
+        // build the form
+        this.moveControllerServiceForm = this.formBuilder.group({
+            processGroups: new FormControl('Process Group', 
Validators.required)
+        });
+
+        const parentControllerServices = 
request.parentControllerServices.filter(
+            (cs) => cs.parentGroupId != request.controllerService.parentGroupId
+        );
+
+        const processGroups: SelectOption[] = [];
+        this.loadParentOption(request.breadcrumb, parentControllerServices, 
processGroups);
+
+        this.loadChildOptions(request.childProcessGroupOptions, 
request.processGroupEntity, processGroups);
+        this.controllerServiceActionProcessGroups = processGroups;
+
+        const firstEnabled = processGroups.findIndex((pg) => !pg.disabled);
+        if (firstEnabled != -1) {
+            
this.moveControllerServiceForm.controls['processGroups'].setValue(processGroups[firstEnabled].value);
+        } else {
+            if (processGroups.length > 0) {
+                
this.moveControllerServiceForm.controls['processGroups'].setValue(processGroups[0].value);
+            }
+            this.disableSubmit = true;
+        }
+    }
+
+    loadParentOption(
+        breadcrumb: BreadcrumbEntity,
+        parentControllerServices: ControllerServiceEntity[],
+        processGroups: SelectOption[]
+    ) {
+        if (breadcrumb.parentBreadcrumb != undefined) {
+            const parentBreadcrumb = breadcrumb.parentBreadcrumb;
+            if (parentBreadcrumb.permissions.canRead && 
parentBreadcrumb.permissions.canWrite) {
+                const option: SelectOption = {
+                    text: parentBreadcrumb.breadcrumb.name + ' (Parent)',
+                    value: parentBreadcrumb.breadcrumb.id
+                };
+
+                let errorMsg = '';
+                const descriptors = 
this.controllerService.component.descriptors;
+                for (const descriptor in descriptors) {
+                    if (descriptors[descriptor].identifiesControllerService != 
undefined) {
+                        const controllerId = 
this.controllerService.component.properties[descriptors[descriptor].name];
+                        if (
+                            controllerId != null &&
+                            !parentControllerServices.some((service) => 
service.id == controllerId)
+                        ) {
+                            errorMsg += '[' + descriptors[descriptor].name + 
']';
+                        }
+                    }
+                }
+
+                if (errorMsg != '') {
+                    option.description =
+                        'The following properties reference controller 
services that would be out of scope for this ' +
+                        'process group: ' +
+                        errorMsg;
+                    option.disabled = true;
+                } else {
+                    option.disabled = false;
+                }
+
+                processGroups.push(option);
+            }
+        }
+    }
+
+    loadChildOptions(
+        childProcessGroupOptions: SelectOption[],
+        currentProcessGroupEntity: any,
+        processGroups: SelectOption[]
+    ) {
+        const referencingComponents: 
ControllerServiceReferencingComponentEntity[] =
+            this.controllerService.component.referencingComponents;
+        childProcessGroupOptions.forEach((child: SelectOption) => {
+            const option: SelectOption = {
+                text: child.text,
+                value: child.value
+            };
+
+            const root = 
this.getProcessGroupById(currentProcessGroupEntity.component, child.value ?? 
'');
+            let errorMsg = '';
+
+            if (root == null) {
+                option.description = 'Error loading process group root.';
+                option.disabled = true;
+                processGroups.push(option);
+            } else {
+                for (const component of referencingComponents) {
+                    if (!this.processGroupContainsComponent(root, 
component.component.groupId)) {
+                        errorMsg += '[' + component.component.name + ']';
+                    }
+                }
+
+                if (errorMsg != '') {
+                    option.description =
+                        'The following components would be out of scope for 
this process group: ' + errorMsg;
+                    option.disabled = true;
+                } else {
+                    option.disabled = false;
+                }
+
+                processGroups.push(option);
+            }
+        });
+    }
+
+    processGroupContainsComponent(processGroup: any, groupId: string): boolean 
{
+        if (processGroup.contents != undefined) {
+            if (processGroup.id == groupId) {
+                return true;
+            } else {
+                for (const pg of processGroup.contents.processGroups) {
+                    if (this.processGroupContainsComponent(pg, groupId)) {
+                        return true;
+                    }
+                }
+                return false;
+            }
+        }
+        return false;
+    }
+
+    getProcessGroupById(root: any, processGroupId: string): any {
+        if (root != undefined) {
+            if (root.id == processGroupId) {
+                return root;
+            } else {
+                if (root.contents != undefined) {

Review Comment:
   ```suggestion
                   if (root.contents) {
   ```



##########
nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/move-controller-service/move-controller-service.component.ts:
##########
@@ -0,0 +1,247 @@
+/*
+ * 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, Inject, Input } from '@angular/core';
+import { MAT_DIALOG_DATA, MatDialogModule } from '@angular/material/dialog';
+import { FormBuilder, FormControl, FormGroup, ReactiveFormsModule, Validators 
} from '@angular/forms';
+import {
+    ControllerServiceEntity,
+    ControllerServiceReferencingComponent,
+    ControllerServiceReferencingComponentEntity
+} from '../../../../state/shared';
+import { MatInputModule } from '@angular/material/input';
+import { MatCheckboxModule } from '@angular/material/checkbox';
+import { MatButtonModule } from '@angular/material/button';
+import { AsyncPipe, NgTemplateOutlet } from '@angular/common';
+import { MatTabsModule } from '@angular/material/tabs';
+import { MatOptionModule } from '@angular/material/core';
+import { MatSelectModule } from '@angular/material/select';
+import { ControllerServiceApi } from 
'../../../../ui/common/controller-service/controller-service-api/controller-service-api.component';
+import { ControllerServiceReferences } from 
'../../../../ui/common/controller-service/controller-service-references/controller-service-references.component';
+import { NifiSpinnerDirective } from 
'../../../../ui/common/spinner/nifi-spinner.directive';
+import { TextTip, NifiTooltipDirective, SelectOption } from '@nifi/shared';
+import { Store } from '@ngrx/store';
+import { CloseOnEscapeDialog } from '@nifi/shared';
+import { moveControllerService } from 
'../../state/controller-services/controller-services.actions';
+import { NiFiState } from 'apps/nifi/src/app/state';
+import { MoveControllerServiceDialogRequestSuccess } from 
'../../state/controller-services';
+import { NgIf } from '@angular/common';
+import { BreadcrumbEntity } from '../../state/shared';
+
+@Component({
+    selector: 'move-controller-service',
+    standalone: true,
+    templateUrl: './move-controller-service.component.html',
+    imports: [
+        ReactiveFormsModule,
+        MatDialogModule,
+        MatInputModule,
+        MatCheckboxModule,
+        MatButtonModule,
+        MatTabsModule,
+        MatOptionModule,
+        MatSelectModule,
+        ControllerServiceApi,
+        ControllerServiceReferences,
+        AsyncPipe,
+        NifiSpinnerDirective,
+        NifiTooltipDirective,
+        NgTemplateOutlet,
+        NgIf
+    ],
+    styleUrls: ['./move-controller-service.component.scss']
+})
+export class MoveControllerService extends CloseOnEscapeDialog {
+    @Input() goToReferencingComponent!: (component: 
ControllerServiceReferencingComponent) => void;
+    protected readonly TextTip = TextTip;
+    protected controllerServiceActionProcessGroups: SelectOption[] = [];
+    protected disableSubmit: boolean = false;
+
+    controllerService: ControllerServiceEntity;
+
+    moveControllerServiceForm: FormGroup;
+
+    constructor(
+        @Inject(MAT_DIALOG_DATA) public request: 
MoveControllerServiceDialogRequestSuccess,
+        private store: Store<NiFiState>,
+        private formBuilder: FormBuilder
+    ) {
+        super();
+
+        this.controllerService = request.controllerService;
+
+        // build the form
+        this.moveControllerServiceForm = this.formBuilder.group({
+            processGroups: new FormControl('Process Group', 
Validators.required)
+        });
+
+        const parentControllerServices = 
request.parentControllerServices.filter(
+            (cs) => cs.parentGroupId != request.controllerService.parentGroupId
+        );
+
+        const processGroups: SelectOption[] = [];
+        this.loadParentOption(request.breadcrumb, parentControllerServices, 
processGroups);
+
+        this.loadChildOptions(request.childProcessGroupOptions, 
request.processGroupEntity, processGroups);
+        this.controllerServiceActionProcessGroups = processGroups;
+
+        const firstEnabled = processGroups.findIndex((pg) => !pg.disabled);
+        if (firstEnabled != -1) {
+            
this.moveControllerServiceForm.controls['processGroups'].setValue(processGroups[firstEnabled].value);
+        } else {
+            if (processGroups.length > 0) {
+                
this.moveControllerServiceForm.controls['processGroups'].setValue(processGroups[0].value);
+            }
+            this.disableSubmit = true;
+        }
+    }
+
+    loadParentOption(
+        breadcrumb: BreadcrumbEntity,
+        parentControllerServices: ControllerServiceEntity[],
+        processGroups: SelectOption[]
+    ) {
+        if (breadcrumb.parentBreadcrumb != undefined) {
+            const parentBreadcrumb = breadcrumb.parentBreadcrumb;
+            if (parentBreadcrumb.permissions.canRead && 
parentBreadcrumb.permissions.canWrite) {
+                const option: SelectOption = {
+                    text: parentBreadcrumb.breadcrumb.name + ' (Parent)',
+                    value: parentBreadcrumb.breadcrumb.id
+                };
+
+                let errorMsg = '';
+                const descriptors = 
this.controllerService.component.descriptors;
+                for (const descriptor in descriptors) {
+                    if (descriptors[descriptor].identifiesControllerService != 
undefined) {
+                        const controllerId = 
this.controllerService.component.properties[descriptors[descriptor].name];
+                        if (
+                            controllerId != null &&
+                            !parentControllerServices.some((service) => 
service.id == controllerId)
+                        ) {
+                            errorMsg += '[' + descriptors[descriptor].name + 
']';
+                        }
+                    }
+                }
+
+                if (errorMsg != '') {
+                    option.description =
+                        'The following properties reference controller 
services that would be out of scope for this ' +
+                        'process group: ' +
+                        errorMsg;
+                    option.disabled = true;
+                } else {
+                    option.disabled = false;
+                }
+
+                processGroups.push(option);
+            }
+        }
+    }
+
+    loadChildOptions(
+        childProcessGroupOptions: SelectOption[],
+        currentProcessGroupEntity: any,
+        processGroups: SelectOption[]
+    ) {
+        const referencingComponents: 
ControllerServiceReferencingComponentEntity[] =
+            this.controllerService.component.referencingComponents;
+        childProcessGroupOptions.forEach((child: SelectOption) => {
+            const option: SelectOption = {
+                text: child.text,
+                value: child.value
+            };
+
+            const root = 
this.getProcessGroupById(currentProcessGroupEntity.component, child.value ?? 
'');
+            let errorMsg = '';
+
+            if (root == null) {
+                option.description = 'Error loading process group root.';
+                option.disabled = true;
+                processGroups.push(option);
+            } else {
+                for (const component of referencingComponents) {
+                    if (!this.processGroupContainsComponent(root, 
component.component.groupId)) {
+                        errorMsg += '[' + component.component.name + ']';
+                    }
+                }
+
+                if (errorMsg != '') {
+                    option.description =
+                        'The following components would be out of scope for 
this process group: ' + errorMsg;

Review Comment:
   Please convert to a template string.



##########
nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/move-controller-service/move-controller-service.component.ts:
##########
@@ -0,0 +1,247 @@
+/*
+ * 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, Inject, Input } from '@angular/core';
+import { MAT_DIALOG_DATA, MatDialogModule } from '@angular/material/dialog';
+import { FormBuilder, FormControl, FormGroup, ReactiveFormsModule, Validators 
} from '@angular/forms';
+import {
+    ControllerServiceEntity,
+    ControllerServiceReferencingComponent,
+    ControllerServiceReferencingComponentEntity
+} from '../../../../state/shared';
+import { MatInputModule } from '@angular/material/input';
+import { MatCheckboxModule } from '@angular/material/checkbox';
+import { MatButtonModule } from '@angular/material/button';
+import { AsyncPipe, NgTemplateOutlet } from '@angular/common';
+import { MatTabsModule } from '@angular/material/tabs';
+import { MatOptionModule } from '@angular/material/core';
+import { MatSelectModule } from '@angular/material/select';
+import { ControllerServiceApi } from 
'../../../../ui/common/controller-service/controller-service-api/controller-service-api.component';
+import { ControllerServiceReferences } from 
'../../../../ui/common/controller-service/controller-service-references/controller-service-references.component';
+import { NifiSpinnerDirective } from 
'../../../../ui/common/spinner/nifi-spinner.directive';
+import { TextTip, NifiTooltipDirective, SelectOption } from '@nifi/shared';
+import { Store } from '@ngrx/store';
+import { CloseOnEscapeDialog } from '@nifi/shared';
+import { moveControllerService } from 
'../../state/controller-services/controller-services.actions';
+import { NiFiState } from 'apps/nifi/src/app/state';
+import { MoveControllerServiceDialogRequestSuccess } from 
'../../state/controller-services';
+import { NgIf } from '@angular/common';
+import { BreadcrumbEntity } from '../../state/shared';
+
+@Component({
+    selector: 'move-controller-service',
+    standalone: true,
+    templateUrl: './move-controller-service.component.html',
+    imports: [
+        ReactiveFormsModule,
+        MatDialogModule,
+        MatInputModule,
+        MatCheckboxModule,
+        MatButtonModule,
+        MatTabsModule,
+        MatOptionModule,
+        MatSelectModule,
+        ControllerServiceApi,
+        ControllerServiceReferences,
+        AsyncPipe,
+        NifiSpinnerDirective,
+        NifiTooltipDirective,
+        NgTemplateOutlet,
+        NgIf
+    ],
+    styleUrls: ['./move-controller-service.component.scss']
+})
+export class MoveControllerService extends CloseOnEscapeDialog {
+    @Input() goToReferencingComponent!: (component: 
ControllerServiceReferencingComponent) => void;
+    protected readonly TextTip = TextTip;
+    protected controllerServiceActionProcessGroups: SelectOption[] = [];
+    protected disableSubmit: boolean = false;
+
+    controllerService: ControllerServiceEntity;
+
+    moveControllerServiceForm: FormGroup;
+
+    constructor(
+        @Inject(MAT_DIALOG_DATA) public request: 
MoveControllerServiceDialogRequestSuccess,
+        private store: Store<NiFiState>,
+        private formBuilder: FormBuilder
+    ) {
+        super();
+
+        this.controllerService = request.controllerService;
+
+        // build the form
+        this.moveControllerServiceForm = this.formBuilder.group({
+            processGroups: new FormControl('Process Group', 
Validators.required)
+        });
+
+        const parentControllerServices = 
request.parentControllerServices.filter(
+            (cs) => cs.parentGroupId != request.controllerService.parentGroupId
+        );
+
+        const processGroups: SelectOption[] = [];
+        this.loadParentOption(request.breadcrumb, parentControllerServices, 
processGroups);
+
+        this.loadChildOptions(request.childProcessGroupOptions, 
request.processGroupEntity, processGroups);
+        this.controllerServiceActionProcessGroups = processGroups;
+
+        const firstEnabled = processGroups.findIndex((pg) => !pg.disabled);
+        if (firstEnabled != -1) {
+            
this.moveControllerServiceForm.controls['processGroups'].setValue(processGroups[firstEnabled].value);
+        } else {
+            if (processGroups.length > 0) {
+                
this.moveControllerServiceForm.controls['processGroups'].setValue(processGroups[0].value);
+            }
+            this.disableSubmit = true;
+        }
+    }
+
+    loadParentOption(
+        breadcrumb: BreadcrumbEntity,
+        parentControllerServices: ControllerServiceEntity[],
+        processGroups: SelectOption[]
+    ) {
+        if (breadcrumb.parentBreadcrumb != undefined) {
+            const parentBreadcrumb = breadcrumb.parentBreadcrumb;
+            if (parentBreadcrumb.permissions.canRead && 
parentBreadcrumb.permissions.canWrite) {
+                const option: SelectOption = {
+                    text: parentBreadcrumb.breadcrumb.name + ' (Parent)',
+                    value: parentBreadcrumb.breadcrumb.id
+                };
+
+                let errorMsg = '';
+                const descriptors = 
this.controllerService.component.descriptors;
+                for (const descriptor in descriptors) {
+                    if (descriptors[descriptor].identifiesControllerService != 
undefined) {
+                        const controllerId = 
this.controllerService.component.properties[descriptors[descriptor].name];
+                        if (
+                            controllerId != null &&
+                            !parentControllerServices.some((service) => 
service.id == controllerId)
+                        ) {
+                            errorMsg += '[' + descriptors[descriptor].name + 
']';
+                        }
+                    }
+                }
+
+                if (errorMsg != '') {
+                    option.description =
+                        'The following properties reference controller 
services that would be out of scope for this ' +
+                        'process group: ' +
+                        errorMsg;
+                    option.disabled = true;
+                } else {
+                    option.disabled = false;
+                }
+
+                processGroups.push(option);
+            }
+        }
+    }
+
+    loadChildOptions(
+        childProcessGroupOptions: SelectOption[],
+        currentProcessGroupEntity: any,
+        processGroups: SelectOption[]
+    ) {
+        const referencingComponents: 
ControllerServiceReferencingComponentEntity[] =
+            this.controllerService.component.referencingComponents;
+        childProcessGroupOptions.forEach((child: SelectOption) => {
+            const option: SelectOption = {
+                text: child.text,
+                value: child.value
+            };
+
+            const root = 
this.getProcessGroupById(currentProcessGroupEntity.component, child.value ?? 
'');
+            let errorMsg = '';
+
+            if (root == null) {
+                option.description = 'Error loading process group root.';
+                option.disabled = true;
+                processGroups.push(option);
+            } else {
+                for (const component of referencingComponents) {
+                    if (!this.processGroupContainsComponent(root, 
component.component.groupId)) {
+                        errorMsg += '[' + component.component.name + ']';
+                    }
+                }
+
+                if (errorMsg != '') {
+                    option.description =
+                        'The following components would be out of scope for 
this process group: ' + errorMsg;
+                    option.disabled = true;
+                } else {
+                    option.disabled = false;
+                }
+
+                processGroups.push(option);
+            }
+        });
+    }
+
+    processGroupContainsComponent(processGroup: any, groupId: string): boolean 
{
+        if (processGroup.contents != undefined) {
+            if (processGroup.id == groupId) {
+                return true;
+            } else {
+                for (const pg of processGroup.contents.processGroups) {
+                    if (this.processGroupContainsComponent(pg, groupId)) {
+                        return true;
+                    }
+                }
+                return false;
+            }
+        }
+        return false;
+    }
+
+    getProcessGroupById(root: any, processGroupId: string): any {
+        if (root != undefined) {
+            if (root.id == processGroupId) {
+                return root;
+            } else {
+                if (root.contents != undefined) {
+                    for (const pg of root.contents.processGroups) {
+                        const result = this.getProcessGroupById(pg, 
processGroupId);
+                        if (result != null) {

Review Comment:
   ```suggestion
                           if (result) {
   ```



##########
nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/service/flow.service.ts:
##########
@@ -118,6 +118,11 @@ export class FlowService implements 
PropertyDescriptorRetriever {
         return this.httpClient.get(`${FlowService.API}/process-groups/${id}`);
     }
 
+    getProcessGroupWithContent(id: string): Observable<any> {
+        const params = new HttpParams().set('includeContent', true);
+        return this.httpClient.get(`${FlowService.API}/process-groups/${id}`, 
{ params: params });
+    }

Review Comment:
   This change is necessary for this feature to ensure that when moving a 
Controller Service it remains in scope for all components currently referencing 
it. However, I have some concerns about fetching the entire flow from the 
current Process Group when the Move dialog is opened. I wonder if we should 
consider a purpose built endpoint for does exactly what we need instead of 
returning everything. We've certainly seen some large flows over the years 
where I worry this could be problematic.
   
   CC @exceptionfactory



##########
nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/move-controller-service/move-controller-service.component.ts:
##########
@@ -0,0 +1,247 @@
+/*
+ * 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, Inject, Input } from '@angular/core';
+import { MAT_DIALOG_DATA, MatDialogModule } from '@angular/material/dialog';
+import { FormBuilder, FormControl, FormGroup, ReactiveFormsModule, Validators 
} from '@angular/forms';
+import {
+    ControllerServiceEntity,
+    ControllerServiceReferencingComponent,
+    ControllerServiceReferencingComponentEntity
+} from '../../../../state/shared';
+import { MatInputModule } from '@angular/material/input';
+import { MatCheckboxModule } from '@angular/material/checkbox';
+import { MatButtonModule } from '@angular/material/button';
+import { AsyncPipe, NgTemplateOutlet } from '@angular/common';
+import { MatTabsModule } from '@angular/material/tabs';
+import { MatOptionModule } from '@angular/material/core';
+import { MatSelectModule } from '@angular/material/select';
+import { ControllerServiceApi } from 
'../../../../ui/common/controller-service/controller-service-api/controller-service-api.component';
+import { ControllerServiceReferences } from 
'../../../../ui/common/controller-service/controller-service-references/controller-service-references.component';
+import { NifiSpinnerDirective } from 
'../../../../ui/common/spinner/nifi-spinner.directive';
+import { TextTip, NifiTooltipDirective, SelectOption } from '@nifi/shared';
+import { Store } from '@ngrx/store';
+import { CloseOnEscapeDialog } from '@nifi/shared';
+import { moveControllerService } from 
'../../state/controller-services/controller-services.actions';
+import { NiFiState } from 'apps/nifi/src/app/state';
+import { MoveControllerServiceDialogRequestSuccess } from 
'../../state/controller-services';
+import { NgIf } from '@angular/common';
+import { BreadcrumbEntity } from '../../state/shared';
+
+@Component({
+    selector: 'move-controller-service',
+    standalone: true,
+    templateUrl: './move-controller-service.component.html',
+    imports: [
+        ReactiveFormsModule,
+        MatDialogModule,
+        MatInputModule,
+        MatCheckboxModule,
+        MatButtonModule,
+        MatTabsModule,
+        MatOptionModule,
+        MatSelectModule,
+        ControllerServiceApi,
+        ControllerServiceReferences,
+        AsyncPipe,
+        NifiSpinnerDirective,
+        NifiTooltipDirective,
+        NgTemplateOutlet,
+        NgIf
+    ],
+    styleUrls: ['./move-controller-service.component.scss']
+})
+export class MoveControllerService extends CloseOnEscapeDialog {
+    @Input() goToReferencingComponent!: (component: 
ControllerServiceReferencingComponent) => void;
+    protected readonly TextTip = TextTip;
+    protected controllerServiceActionProcessGroups: SelectOption[] = [];
+    protected disableSubmit: boolean = false;
+
+    controllerService: ControllerServiceEntity;
+
+    moveControllerServiceForm: FormGroup;
+
+    constructor(
+        @Inject(MAT_DIALOG_DATA) public request: 
MoveControllerServiceDialogRequestSuccess,
+        private store: Store<NiFiState>,
+        private formBuilder: FormBuilder
+    ) {
+        super();
+
+        this.controllerService = request.controllerService;
+
+        // build the form
+        this.moveControllerServiceForm = this.formBuilder.group({
+            processGroups: new FormControl('Process Group', 
Validators.required)
+        });
+
+        const parentControllerServices = 
request.parentControllerServices.filter(
+            (cs) => cs.parentGroupId != request.controllerService.parentGroupId
+        );
+
+        const processGroups: SelectOption[] = [];
+        this.loadParentOption(request.breadcrumb, parentControllerServices, 
processGroups);
+
+        this.loadChildOptions(request.childProcessGroupOptions, 
request.processGroupEntity, processGroups);
+        this.controllerServiceActionProcessGroups = processGroups;
+
+        const firstEnabled = processGroups.findIndex((pg) => !pg.disabled);
+        if (firstEnabled != -1) {
+            
this.moveControllerServiceForm.controls['processGroups'].setValue(processGroups[firstEnabled].value);
+        } else {
+            if (processGroups.length > 0) {
+                
this.moveControllerServiceForm.controls['processGroups'].setValue(processGroups[0].value);
+            }
+            this.disableSubmit = true;
+        }
+    }
+
+    loadParentOption(
+        breadcrumb: BreadcrumbEntity,
+        parentControllerServices: ControllerServiceEntity[],
+        processGroups: SelectOption[]
+    ) {
+        if (breadcrumb.parentBreadcrumb != undefined) {
+            const parentBreadcrumb = breadcrumb.parentBreadcrumb;
+            if (parentBreadcrumb.permissions.canRead && 
parentBreadcrumb.permissions.canWrite) {
+                const option: SelectOption = {
+                    text: parentBreadcrumb.breadcrumb.name + ' (Parent)',
+                    value: parentBreadcrumb.breadcrumb.id
+                };
+
+                let errorMsg = '';
+                const descriptors = 
this.controllerService.component.descriptors;
+                for (const descriptor in descriptors) {
+                    if (descriptors[descriptor].identifiesControllerService != 
undefined) {
+                        const controllerId = 
this.controllerService.component.properties[descriptors[descriptor].name];
+                        if (
+                            controllerId != null &&
+                            !parentControllerServices.some((service) => 
service.id == controllerId)
+                        ) {
+                            errorMsg += '[' + descriptors[descriptor].name + 
']';
+                        }
+                    }
+                }
+
+                if (errorMsg != '') {
+                    option.description =
+                        'The following properties reference controller 
services that would be out of scope for this ' +
+                        'process group: ' +
+                        errorMsg;
+                    option.disabled = true;
+                } else {
+                    option.disabled = false;
+                }
+
+                processGroups.push(option);
+            }
+        }
+    }
+
+    loadChildOptions(
+        childProcessGroupOptions: SelectOption[],
+        currentProcessGroupEntity: any,
+        processGroups: SelectOption[]
+    ) {
+        const referencingComponents: 
ControllerServiceReferencingComponentEntity[] =
+            this.controllerService.component.referencingComponents;
+        childProcessGroupOptions.forEach((child: SelectOption) => {
+            const option: SelectOption = {
+                text: child.text,
+                value: child.value
+            };
+
+            const root = 
this.getProcessGroupById(currentProcessGroupEntity.component, child.value ?? 
'');
+            let errorMsg = '';
+
+            if (root == null) {

Review Comment:
   It may make this more readable  if we swap the `if/else` blocks and update 
the conditional to:
   
   ```suggestion
               if (root) {
   ```



##########
nifi-frontend/src/main/frontend/apps/nifi/src/app/pages/flow-designer/ui/move-controller-service/move-controller-service.component.ts:
##########
@@ -0,0 +1,247 @@
+/*
+ * 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, Inject, Input } from '@angular/core';
+import { MAT_DIALOG_DATA, MatDialogModule } from '@angular/material/dialog';
+import { FormBuilder, FormControl, FormGroup, ReactiveFormsModule, Validators 
} from '@angular/forms';
+import {
+    ControllerServiceEntity,
+    ControllerServiceReferencingComponent,
+    ControllerServiceReferencingComponentEntity
+} from '../../../../state/shared';
+import { MatInputModule } from '@angular/material/input';
+import { MatCheckboxModule } from '@angular/material/checkbox';
+import { MatButtonModule } from '@angular/material/button';
+import { AsyncPipe, NgTemplateOutlet } from '@angular/common';
+import { MatTabsModule } from '@angular/material/tabs';
+import { MatOptionModule } from '@angular/material/core';
+import { MatSelectModule } from '@angular/material/select';
+import { ControllerServiceApi } from 
'../../../../ui/common/controller-service/controller-service-api/controller-service-api.component';
+import { ControllerServiceReferences } from 
'../../../../ui/common/controller-service/controller-service-references/controller-service-references.component';
+import { NifiSpinnerDirective } from 
'../../../../ui/common/spinner/nifi-spinner.directive';
+import { TextTip, NifiTooltipDirective, SelectOption } from '@nifi/shared';
+import { Store } from '@ngrx/store';
+import { CloseOnEscapeDialog } from '@nifi/shared';
+import { moveControllerService } from 
'../../state/controller-services/controller-services.actions';
+import { NiFiState } from 'apps/nifi/src/app/state';
+import { MoveControllerServiceDialogRequestSuccess } from 
'../../state/controller-services';
+import { NgIf } from '@angular/common';
+import { BreadcrumbEntity } from '../../state/shared';
+
+@Component({
+    selector: 'move-controller-service',
+    standalone: true,
+    templateUrl: './move-controller-service.component.html',
+    imports: [
+        ReactiveFormsModule,
+        MatDialogModule,
+        MatInputModule,
+        MatCheckboxModule,
+        MatButtonModule,
+        MatTabsModule,
+        MatOptionModule,
+        MatSelectModule,
+        ControllerServiceApi,
+        ControllerServiceReferences,
+        AsyncPipe,
+        NifiSpinnerDirective,
+        NifiTooltipDirective,
+        NgTemplateOutlet,
+        NgIf
+    ],
+    styleUrls: ['./move-controller-service.component.scss']
+})
+export class MoveControllerService extends CloseOnEscapeDialog {
+    @Input() goToReferencingComponent!: (component: 
ControllerServiceReferencingComponent) => void;
+    protected readonly TextTip = TextTip;
+    protected controllerServiceActionProcessGroups: SelectOption[] = [];
+    protected disableSubmit: boolean = false;
+
+    controllerService: ControllerServiceEntity;
+
+    moveControllerServiceForm: FormGroup;
+
+    constructor(
+        @Inject(MAT_DIALOG_DATA) public request: 
MoveControllerServiceDialogRequestSuccess,
+        private store: Store<NiFiState>,
+        private formBuilder: FormBuilder
+    ) {
+        super();
+
+        this.controllerService = request.controllerService;
+
+        // build the form
+        this.moveControllerServiceForm = this.formBuilder.group({
+            processGroups: new FormControl('Process Group', 
Validators.required)
+        });
+
+        const parentControllerServices = 
request.parentControllerServices.filter(
+            (cs) => cs.parentGroupId != request.controllerService.parentGroupId
+        );
+
+        const processGroups: SelectOption[] = [];
+        this.loadParentOption(request.breadcrumb, parentControllerServices, 
processGroups);
+
+        this.loadChildOptions(request.childProcessGroupOptions, 
request.processGroupEntity, processGroups);
+        this.controllerServiceActionProcessGroups = processGroups;
+
+        const firstEnabled = processGroups.findIndex((pg) => !pg.disabled);
+        if (firstEnabled != -1) {
+            
this.moveControllerServiceForm.controls['processGroups'].setValue(processGroups[firstEnabled].value);
+        } else {
+            if (processGroups.length > 0) {
+                
this.moveControllerServiceForm.controls['processGroups'].setValue(processGroups[0].value);
+            }
+            this.disableSubmit = true;
+        }
+    }
+
+    loadParentOption(
+        breadcrumb: BreadcrumbEntity,
+        parentControllerServices: ControllerServiceEntity[],
+        processGroups: SelectOption[]
+    ) {
+        if (breadcrumb.parentBreadcrumb != undefined) {
+            const parentBreadcrumb = breadcrumb.parentBreadcrumb;
+            if (parentBreadcrumb.permissions.canRead && 
parentBreadcrumb.permissions.canWrite) {
+                const option: SelectOption = {
+                    text: parentBreadcrumb.breadcrumb.name + ' (Parent)',
+                    value: parentBreadcrumb.breadcrumb.id
+                };
+
+                let errorMsg = '';
+                const descriptors = 
this.controllerService.component.descriptors;
+                for (const descriptor in descriptors) {
+                    if (descriptors[descriptor].identifiesControllerService != 
undefined) {
+                        const controllerId = 
this.controllerService.component.properties[descriptors[descriptor].name];
+                        if (
+                            controllerId != null &&
+                            !parentControllerServices.some((service) => 
service.id == controllerId)
+                        ) {
+                            errorMsg += '[' + descriptors[descriptor].name + 
']';
+                        }
+                    }
+                }
+
+                if (errorMsg != '') {
+                    option.description =
+                        'The following properties reference controller 
services that would be out of scope for this ' +
+                        'process group: ' +
+                        errorMsg;
+                    option.disabled = true;
+                } else {
+                    option.disabled = false;
+                }
+
+                processGroups.push(option);
+            }
+        }
+    }
+
+    loadChildOptions(
+        childProcessGroupOptions: SelectOption[],
+        currentProcessGroupEntity: any,
+        processGroups: SelectOption[]
+    ) {
+        const referencingComponents: 
ControllerServiceReferencingComponentEntity[] =
+            this.controllerService.component.referencingComponents;
+        childProcessGroupOptions.forEach((child: SelectOption) => {
+            const option: SelectOption = {
+                text: child.text,
+                value: child.value
+            };
+
+            const root = 
this.getProcessGroupById(currentProcessGroupEntity.component, child.value ?? 
'');
+            let errorMsg = '';
+
+            if (root == null) {
+                option.description = 'Error loading process group root.';
+                option.disabled = true;
+                processGroups.push(option);
+            } else {
+                for (const component of referencingComponents) {
+                    if (!this.processGroupContainsComponent(root, 
component.component.groupId)) {
+                        errorMsg += '[' + component.component.name + ']';
+                    }
+                }
+
+                if (errorMsg != '') {
+                    option.description =
+                        'The following components would be out of scope for 
this process group: ' + errorMsg;
+                    option.disabled = true;
+                } else {
+                    option.disabled = false;
+                }
+
+                processGroups.push(option);
+            }
+        });
+    }
+
+    processGroupContainsComponent(processGroup: any, groupId: string): boolean 
{
+        if (processGroup.contents != undefined) {
+            if (processGroup.id == groupId) {
+                return true;
+            } else {
+                for (const pg of processGroup.contents.processGroups) {
+                    if (this.processGroupContainsComponent(pg, groupId)) {
+                        return true;
+                    }
+                }
+                return false;
+            }
+        }
+        return false;
+    }
+
+    getProcessGroupById(root: any, processGroupId: string): any {
+        if (root != undefined) {

Review Comment:
   ```suggestion
           if (root) {
   ```



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]

Reply via email to