rfellows commented on code in PR #11773:
URL: https://github.com/apache/nifi/pull/11773#discussion_r4211590756


##########
nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/canvas/canvas.component.ts:
##########
@@ -566,14 +692,33 @@ export class CanvasComponent implements OnInit, 
AfterViewInit, OnDestroy {
 
         if (connectionsChanged) {
             // Connections changed (added/removed) - create new array
+            const previous = this._internalConnections;
             this._internalConnections = this.mapConnections(inputConnections);
+            for (const connection of this._internalConnections) {
+                const existing = previous.find((item) => item.entity.id === 
connection.entity.id);
+                if (!existing) continue;
+                connection.ui.dragStartEntity = existing.ui.dragStartEntity;
+                connection.ui.dragStartRevision = 
existing.ui.dragStartRevision;
+                connection.ui.dragStartBends = existing.ui.dragStartBends;
+                connection.ui.dragging = existing.ui.dragging;
+                connection.ui.bends = existing.ui.bends;

Review Comment:
   This changes the pre-PR reconstruction behavior for surviving connections. 
When any connection is added or removed, the rebuilt wrapper now retains its 
previous `ui.bends` even when idle. Previously, rebuilding left `ui.bends` 
unset so rendering initialized it from the latest server entity.
   
   A polling response that changes both the connection set and a surviving 
connection's geometry can therefore display stale bends. Because 
[NIFI-16397](https://issues.apache.org/jira/browse/NIFI-16397) requires 
bend-point behavior to remain unchanged, could we preserve the previous bends 
only while that connection is actively dragging or saving? Otherwise, the 
rebuilt wrapper should use the latest entity geometry.
   
   A regression test where a sibling connection is added or removed while an 
idle surviving connection receives new bends would cover this path.



##########
nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/canvas/canvas.component.ts:
##########
@@ -2267,6 +2479,160 @@ export class CanvasComponent implements OnInit, 
AfterViewInit, OnDestroy {
         this.componentDoubleClick.emit({ entity: processor.entity, 
componentType: ComponentType.Processor });
     }
 
+    onDragEnd({ delta, movingIds }: { delta: Position; movingIds: Set<string> 
}): void {
+        const targetGroupId = DragUtils.readDropTarget(this.canvasGroup) ?? 
undefined;
+        if (delta.x === 0 && delta.y === 0 && !targetGroupId) {
+            this.clearNoOpDragState(movingIds);
+            return;
+        }
+
+        const items: DragEndBaselineItem[] = [];
+        const includedIds = new Set<string>();
+        for (const id of movingIds) {
+            const type = this.componentTypeMap().get(id);
+            if (type === undefined) {
+                continue;
+            }
+            const wrapper = this.findWrapperByType(id, type);
+            if (!wrapper) {
+                continue;
+            }
+            const source = wrapper.ui.dragStartEntity ?? wrapper.entity;
+            items.push(this.projectBaselineItem(id, type as 
DragEndBaselineItem['type'], source));
+            includedIds.add(id);
+            delete wrapper.ui.dragStartEntity;
+        }
+        for (const connection of this.internalConnections()) {
+            const entity = connection.entity;
+            if (
+                entity.sourceId === entity.destinationId &&
+                movingIds.has(entity.sourceId) &&
+                !includedIds.has(entity.id)
+            ) {
+                const source = connection.ui.dragStartEntity ?? entity;
+                items.push(this.projectBaselineItem(entity.id, 
ComponentType.Connection, source));
+                delete connection.ui.dragStartEntity;
+            }
+        }
+
+        if (items.length === 0) {
+            return;
+        }
+        this.markItemsSaving(items);

Review Comment:
   Nonblocking planning note for the future editable workflow: every emitted 
item is marked as saving, but there is no generic way for a consumer to release 
items it decides not to persist. Also, after drag emission removes 
`dragStartEntity`, a same-type sibling reconstruction no longer preserves 
`currentPosition`, so an optimistic position can snap back before confirmation.
   
   These are beyond NIFI-16397's typing scope, but they should be addressed 
when bulk position persistence and failure recovery are wired. 
[NIFI-16403](https://issues.apache.org/jira/browse/NIFI-16403) is the closest 
planned owner. If that issue is intended to cover alignment only, we may need a 
separate child Jira for manual drag persistence and saving-state cleanup.



##########
nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/canvas/canvas.component.ts:
##########
@@ -277,6 +337,46 @@ export class CanvasComponent implements OnInit, 
AfterViewInit, OnDestroy {
     private savingPorts = signal<Set<string>>(new Set());
     disabledPortIds = computed(() => this.savingPorts());
 
+    connectableBehavior = computed<ConnectableBehaviorHelper | null>(() => {
+        const policy = this.connectable();
+        if (!policy) {
+            return null;
+        }
+        return new ConnectableBehaviorHelper(
+            policy,
+            {
+                onSelectSource: (source) => 
this.selectComponents.emit([source]),
+                onConnectionRequested: (payload) => 
this.createConnectionRequested.emit(payload),
+                isDisabled: (id) =>
+                    this.disabledProcessorIds().has(id) ||
+                    this.disabledFunnelIds().has(id) ||
+                    this.disabledPortIds().has(id) ||
+                    this.disabledProcessGroupIds().has(id) ||
+                    this.disabledRemoteProcessGroupIds().has(id)
+            },
+            this.canvasRootResolver
+        );
+    });
+
+    connectionReconnect = computed<ConnectionReconnectContext | null>(() => {
+        const policy = this.connectable();
+        if (!policy) {
+            return null;
+        }
+        const geometry = { ...DEFAULT_GEOMETRY, ...policy.geometry };
+        return {
+            isValidConnectionDestination: (selection) => 
policy.isValidConnectionDestination(selection),
+            getPerimeterPoint: geometry.getPerimeterPoint,
+            selfLoopXOffset: geometry.selfLoopXOffset,
+            selfLoopYOffset: geometry.selfLoopYOffset
+        };
+    });
+
+    readonly canvasRootResolver: CanvasRootResolver = () =>
+        this.canvasGroup ?? d3.select<SVGGElement, unknown>(null as unknown as 
SVGGElement);
+
+    readonly isDropAllowedFn = (): boolean => true;

Review Comment:
   Nonblocking planning note: once editable troubleshooting is enabled, this 
unconditional result can expose the child-process-group drop affordance for 
connected selections that should not be movable as a disconnected unit.
   
   I do not think this interaction-semantic change belongs in NIFI-16397. Could 
it be tracked under 
[NIFI-16400](https://issues.apache.org/jira/browse/NIFI-16400), where 
troubleshooting state begins enabling canvas editing, or under a dedicated 
follow-up if that ticket is not intended to own drag/drop eligibility?



##########
nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/canvas/canvas.component.spec.ts:
##########
@@ -1605,4 +1690,335 @@ describe('CanvasComponent', () => {
             });
         });
     });
+
+    describe('typed reusable-canvas contracts', () => {
+        it('keeps connectable behavior inert when no policy is supplied', 
async () => {
+            const { component } = await setup();
+
+            expect(component.connectable()).toBeNull();
+            expect(component.connectableBehavior()).toBeNull();
+            expect(component.connectionReconnect()).toBeNull();
+        });
+
+        it('replaces connectable listeners and removes them when policy 
changes to null', async () => {
+            const { fixture } = await setup({
+                processors: [createMockProcessor({ id: 'processor-1' })],
+                dataReady: true
+            });
+            const firstPolicy: ConnectablePolicy = {
+                isValidConnectionSource: vi.fn(() => true),
+                isValidConnectionDestination: vi.fn(() => true)
+            };
+            const secondPolicy: ConnectablePolicy = {
+                isValidConnectionSource: vi.fn(() => true),
+                isValidConnectionDestination: vi.fn(() => true)
+            };
+            fixture.componentRef.setInput('connectable', firstPolicy);
+            fixture.detectChanges();
+            const processor = 
fixture.nativeElement.querySelector('g.processor') as SVGGElement;
+
+            processor.dispatchEvent(new MouseEvent('mouseenter'));
+            expect(processor.querySelector('text.add-connect')).not.toBeNull();
+
+            fixture.componentRef.setInput('connectable', secondPolicy);
+            fixture.detectChanges();
+            expect(processor.querySelector('text.add-connect')).toBeNull();
+            processor.dispatchEvent(new MouseEvent('mouseenter'));
+            
expect(firstPolicy.isValidConnectionSource).toHaveBeenCalledTimes(1);
+            
expect(secondPolicy.isValidConnectionSource).toHaveBeenCalledTimes(1);
+
+            fixture.componentRef.setInput('connectable', null);
+            fixture.detectChanges();
+            expect(processor.querySelector('text.add-connect')).toBeNull();
+            const sourceChecks = 
vi.mocked(secondPolicy.isValidConnectionSource).mock.calls.length;
+
+            processor.dispatchEvent(new MouseEvent('mouseenter'));
+            
expect(secondPolicy.isValidConnectionSource).toHaveBeenCalledTimes(sourceChecks);
+            expect(processor.querySelector('text.add-connect')).toBeNull();
+        });
+
+        it('deactivates an active create-connection gesture when the canvas is 
destroyed', async () => {
+            const { component, fixture } = await setup({
+                processors: [createMockProcessor({ id: 'processor-1' })],
+                dataReady: true
+            });
+            const policy: ConnectablePolicy = {
+                isValidConnectionSource: vi.fn(() => true),
+                isValidConnectionDestination: vi.fn(() => true)
+            };
+            fixture.componentRef.setInput('connectable', policy);
+            fixture.detectChanges();
+            const helper = component.connectableBehavior()!;
+            const deactivate = vi.spyOn(helper, 'deactivate');
+            const processor = 
fixture.nativeElement.querySelector('g.processor') as SVGGElement;
+            processor.dispatchEvent(new MouseEvent('mouseenter'));
+            const canvasRoot = asPrivate(component).canvasGroup;
+            const handle = processor.querySelector('text.add-connect')!;
+            d3.select(handle).classed('dragging', true);
+            canvasRoot.node()!.appendChild(handle);
+            canvasRoot.append('path').attr('class', 'connector');
+
+            fixture.destroy();
+
+            expect(deactivate).toHaveBeenCalled();
+            expect(canvasRoot.select('text.add-connect').empty()).toBe(true);
+            expect(canvasRoot.select('path.connector').empty()).toBe(true);
+        });
+
+        it('clears component and self-loop drag state without emitting for a 
zero-delta drop', async () => {
+            const processor = createMockProcessor({ id: 'processor-1' });
+            processor.revision = { version: 4 };
+            const connection = {
+                id: 'connection-1',
+                uri: 'https://localhost/nifi-api/connections/connection-1',
+                revision: { version: 7 },
+                permissions: { canRead: true, canWrite: true },
+                sourceId: 'processor-1',
+                destinationId: 'processor-1',
+                bends: [{ x: 25, y: 30 }],
+                component: { id: 'connection-1', source: {}, destination: {} }
+            } as unknown as ConnectionEntity;
+            const { component } = await setup({ processors: [processor], 
connections: [connection] });
+            const processorDatum = component.internalProcessors()[0];
+            const connectionDatum = component.internalConnections()[0];
+            processorDatum.ui.dragStartEntity = processor;
+            processorDatum.ui.dragStartPosition = { ...processor.position };
+            processorDatum.ui.currentPosition = { ...processor.position };
+            processorDatum.ui.dragDelta = { x: 0, y: 0 };
+            processorDatum.ui.dragMovingIds = new Set(['processor-1']);
+            connectionDatum.ui.dragStartEntity = connection;
+            connectionDatum.ui.dragStartBends = [{ x: 25, y: 30 }];
+            connectionDatum.ui.bends = [{ x: 25, y: 30 }];
+            const emitted: unknown[] = [];
+            outputToObservable(component.componentsDragEnd).subscribe((event) 
=> emitted.push(event));
+
+            component.onDragEnd({ delta: { x: 0, y: 0 }, movingIds: new 
Set(['processor-1']) });
+
+            expect(emitted).toEqual([]);
+            expect(processorDatum.ui.dragStartEntity).toBeUndefined();
+            expect(processorDatum.ui.dragStartPosition).toBeUndefined();
+            expect(processorDatum.ui.currentPosition).toBeUndefined();
+            expect(processorDatum.ui.dragDelta).toBeUndefined();
+            expect(processorDatum.ui.dragMovingIds).toBeUndefined();
+            expect(connectionDatum.ui.dragStartEntity).toBeUndefined();
+            expect(connectionDatum.ui.dragStartBends).toBeUndefined();
+            expect(connectionDatum.ui.bends).toEqual(connection.bends);
+        });
+
+        it('refreshes idle connection bends from a same-id entity update', 
async () => {
+            const connection = {
+                id: 'connection-1',
+                uri: 'https://localhost/nifi-api/connections/connection-1',
+                revision: { version: 1 },
+                permissions: { canRead: true, canWrite: true },
+                sourceId: 'source-1',
+                destinationId: 'dest-1',
+                bends: [{ x: 10, y: 20 }],
+                component: { id: 'connection-1', source: {}, destination: {} }
+            } as unknown as ConnectionEntity;
+            const { fixture, component } = await setup({ connections: 
[connection] });
+            const wrapper = component.internalConnections()[0];
+            wrapper.ui.bends = [{ x: 10, y: 20 }];
+            const updatedConnection = {
+                ...connection,
+                revision: { version: 2 },
+                bends: [{ x: 30, y: 40 }]
+            };
+
+            fixture.componentRef.setInput('connections', [updatedConnection]);
+            fixture.detectChanges();
+
+            
expect(component.internalConnections()[0].entity).toBe(updatedConnection);
+            expect(component.internalConnections()[0].ui.bends).toEqual([{ x: 
30, y: 40 }]);
+            
expect(component.internalConnections()[0].ui.bends).not.toBe(updatedConnection.bends);
+        });
+
+        it('preserves optimistic connection bends during an active drag', 
async () => {
+            const connection = {
+                id: 'connection-1',
+                uri: 'https://localhost/nifi-api/connections/connection-1',
+                revision: { version: 1 },
+                permissions: { canRead: true, canWrite: true },
+                sourceId: 'source-1',
+                destinationId: 'dest-1',
+                bends: [{ x: 10, y: 20 }],
+                component: { id: 'connection-1', source: {}, destination: {} }
+            } as unknown as ConnectionEntity;
+            const { fixture, component } = await setup({ connections: 
[connection] });
+            const wrapper = component.internalConnections()[0];
+            wrapper.ui.dragging = true;
+            wrapper.ui.bends = [{ x: 50, y: 60 }];
+            const updatedConnection = {
+                ...connection,
+                revision: { version: 2 },
+                bends: [{ x: 30, y: 40 }]
+            };
+
+            fixture.componentRef.setInput('connections', [updatedConnection]);
+            fixture.detectChanges();
+
+            
expect(component.internalConnections()[0].entity).toBe(updatedConnection);
+            expect(component.internalConnections()[0].ui.bends).toEqual([{ x: 
50, y: 60 }]);
+        });
+
+        it('preserves optimistic connection bends while a save is pending', 
async () => {
+            const connection = {
+                id: 'connection-1',
+                uri: 'https://localhost/nifi-api/connections/connection-1',
+                revision: { version: 1 },
+                permissions: { canRead: true, canWrite: true },
+                sourceId: 'source-1',
+                destinationId: 'dest-1',
+                bends: [{ x: 10, y: 20 }],
+                component: { id: 'connection-1', source: {}, destination: {} }
+            } as unknown as ConnectionEntity;
+            const { fixture, component } = await setup({ connections: 
[connection] });
+            const wrapper = component.internalConnections()[0];
+            wrapper.ui.bends = [{ x: 50, y: 60 }];
+            asPrivate(component).savingConnections.set(new 
Set(['connection-1']));
+            const updatedConnection = {
+                ...connection,
+                revision: { version: 2 },
+                bends: [{ x: 30, y: 40 }]
+            };
+
+            fixture.componentRef.setInput('connections', [updatedConnection]);
+            fixture.detectChanges();
+
+            
expect(component.internalConnections()[0].entity).toBe(updatedConnection);
+            expect(component.internalConnections()[0].ui.bends).toEqual([{ x: 
50, y: 60 }]);
+        });
+
+        it('preserves optimistic reconnect state across wrapper rebuilds and 
confirms it', async () => {
+            const connection = {
+                id: 'connection-1',
+                uri: 'https://localhost/nifi-api/connections/connection-1',
+                revision: { version: 7 },
+                permissions: { canRead: true, canWrite: true },
+                sourceId: 'source-1',
+                destinationId: 'dest-1',
+                bends: [],
+                component: { id: 'connection-1', source: {}, destination: {} }
+            } as unknown as ConnectionEntity;
+            const addedConnection = {
+                ...connection,
+                id: 'connection-2',
+                uri: 'https://localhost/nifi-api/connections/connection-2',
+                component: { ...connection.component, id: 'connection-2' }
+            } as ConnectionEntity;
+            const destination = createMockProcessor({ id: 'dest-2' });
+            const { fixture, component } = await setup({ connections: 
[connection] });
+            const wrapper = component.internalConnections()[0];
+            wrapper.ui.dragStartRevision = { version: 3 };
+            const emitted: Array<{ id: string; bends?: Array<{ x: number; y: 
number }> }> = [];
+            
outputToObservable(component.connectionDestinationChangeRequested).subscribe((event)
 =>
+                emitted.push(event)
+            );
+
+            component.onConnectionEndpointReconnect({
+                connection: wrapper,
+                newDestination: {
+                    id: 'dest-2',
+                    componentType: ComponentType.Processor,
+                    entity: destination
+                },
+                bends: [{ x: 10, y: 20 }]
+            });
+
+            expect(wrapper.ui.reconnectDestinationId).toBe('dest-2');
+            expect(wrapper.ui.bends).toEqual([{ x: 10, y: 20 }]);
+            expect(wrapper.ui.dragStartRevision).toBeUndefined();
+            expect(emitted).toEqual([
+                {
+                    id: 'connection-1',
+                    revision: { version: 3 },
+                    newDestination: expect.any(Object),
+                    bends: [{ x: 10, y: 20 }]
+                }
+            ]);
+
+            fixture.componentRef.setInput('connections', [connection, 
addedConnection]);
+            fixture.detectChanges();
+            const rebuilt = component.internalConnections().find((item) => 
item.entity.id === 'connection-1')!;
+            expect(rebuilt.ui.reconnectDestinationId).toBe('dest-2');
+            expect(rebuilt.ui.bends).toEqual([{ x: 10, y: 20 }]);
+
+            component.confirmConnectionDestination('connection-1');
+            
expect(component.disabledConnectionIds().has('connection-1')).toBe(false);
+            expect(rebuilt.ui.reconnectDestinationId).toBeUndefined();
+            expect(rebuilt.ui.bends).toBeUndefined();
+        });
+
+        it('reverts optimistic reconnect destination and bends', async () => {
+            const connection = {
+                id: 'connection-1',
+                uri: 'https://localhost/nifi-api/connections/connection-1',
+                revision: { version: 7 },
+                permissions: { canRead: true, canWrite: true },
+                sourceId: 'source-1',
+                destinationId: 'dest-1',
+                bends: [],
+                component: { id: 'connection-1', source: {}, destination: {} }
+            } as unknown as ConnectionEntity;
+            const destination = createMockProcessor({ id: 'dest-2' });
+            const { component } = await setup({ connections: [connection] });
+            const wrapper = component.internalConnections()[0];
+            component.onConnectionEndpointReconnect({
+                connection: wrapper,
+                newDestination: {
+                    id: 'dest-2',
+                    componentType: ComponentType.Processor,
+                    entity: destination
+                },
+                bends: [{ x: 10, y: 20 }]
+            });
+            wrapper.ui.endPointDragging = true;
+
+            component.revertConnectionDestination('connection-1');
+
+            
expect(component.disabledConnectionIds().has('connection-1')).toBe(false);
+            expect(wrapper.ui.reconnectDestinationId).toBeUndefined();
+            expect(wrapper.ui.endPointDragging).toBeUndefined();
+            expect(wrapper.ui.bends).toBeUndefined();
+        });
+
+        it('emits the label resize gesture baseline revision', async () => {
+            const { component } = await setup();
+            const emitted: Array<{ revision: { version: number } }> = [];

Review Comment:
   These two new event tests require `revision.version`, but the canonical 
`RevisionRequest.version` property is optional. A direct spec TypeScript check 
reports both subscriptions because the test's collection type is narrower than 
the output contract.
   
   Since NIFI-16397 explicitly covers canonical gesture payloads and 
compile-time validation, could these use `RevisionRequest` or infer the event 
type from the output? The same adjustment is needed in the connection-label 
test below.



##########
nifi-frontend/src/main/frontend/apps/nifi/src/app/ui/common/canvas/canvas.component.spec.ts:
##########
@@ -643,6 +682,52 @@ describe('CanvasComponent', () => {
             const data = component.getBirdseyeComponentData();
             expect(data.length).toBe(1);
         });
+
+        it('should preserve the viewport when polling toggles dataReady for 
the same process group', async () => {

Review Comment:
   The production change looks correct, but this test never places the canvas 
in its initialized state because `internalCanvasReady` remains false. As 
written, the previous implementation could also pass without invoking either 
viewport path.
   
   This is nonblocking for NIFI-16397 because polling behavior predates its 
scope. If we retain the fix here, it would still be valuable to make the test 
exercise an initialized canvas; otherwise, this coverage could follow under 
[NIFI-16400](https://issues.apache.org/jira/browse/NIFI-16400), which owns 
troubleshooting polling and canvas-state transitions.



-- 
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