merge: dynamic storage operations editor

This commit is contained in:
Lucio Lelii 2026-09-30 09:22:41 +02:00
commit 5dc537146d
14 changed files with 277 additions and 33 deletions

View File

@ -4,17 +4,30 @@
import { Injectable, signal } from '@angular/core';
export type SelectedConnection = {
id: string;
sourceId: string;
sourceOutput: string;
targetId: string;
targetInput: string;
};
@Injectable({ providedIn: 'root' })
export class GraphSelectionService {
readonly selectedConnectionId = signal<string | null>(null);
/** The two endpoints let their socket components light up with the selected wire. */
readonly selectedConnection = signal<SelectedConnection | null>(null);
readonly deleteConnectionRequestTick = signal(0);
selectConnection(connectionId: string | null | undefined) {
this.selectedConnectionId.set(connectionId ? String(connectionId) : null);
selectConnection(connectionId: string | null | undefined, endpoints?: Omit<SelectedConnection, 'id'>) {
const id = connectionId ? String(connectionId) : null;
this.selectedConnectionId.set(id);
this.selectedConnection.set(id && endpoints ? { id, ...endpoints } : null);
}
clearConnectionSelection() {
this.selectedConnectionId.set(null);
this.selectedConnection.set(null);
}
requestDeleteSelectedConnection() {

View File

@ -65,4 +65,16 @@ describe('CustomConnectionComponent bias routing highlight', () => {
comparisonViewState.show({ report: REPORT });
expect(component.strokeColor).toBe('#4682b4');
});
it('keeps a loop at its assigned height while a connected node moves above it', () => {
component.data = {
...component.data,
__loopBack: true,
__loopTop: 120
} as any;
component.start = { x: 0, y: 40 };
component.end = { x: 100, y: 80 };
expect(component.drawnPath).toContain('L 100 120');
});
});

View File

@ -20,7 +20,9 @@ import { BiasComparisonViewStateService } from '@services/bias/bias-comparison-v
selector: 'app-custom-connection',
standalone: true,
template: `
<svg data-testid="connection" [attr.data-connection-id]="connectionId" [attr.data-loop-back]="isLoopBack || null">
<svg data-testid="connection" [attr.data-connection-id]="connectionId" [attr.data-loop-back]="isLoopBack || null"
[attr.data-connection-kind]="connectionKind" [attr.data-selected]="isSelected || null"
[style.z-index]="isSelected ? 100 : null">
<path
[class.connection-readonly]="isReadonly"
[attr.d]="drawnPath"
@ -214,6 +216,10 @@ export class CustomConnectionComponent {
return String(this.data?.id ?? '');
}
get connectionKind(): 'data' | 'dependency' | 'resource' {
return this.isResourceLink ? 'resource' : this.isDependencyConnection ? 'dependency' : 'data';
}
private get loopData(): LoopAwareConnection | undefined {
return this.data as LoopAwareConnection | undefined;
}
@ -261,7 +267,7 @@ export class CustomConnectionComponent {
private get loopTop(): number {
const ports = Math.min(this.start?.y ?? 0, this.end?.y ?? 0) - 90;
const nodes = this.loopData?.__loopTop;
return typeof nodes === 'number' ? Math.min(nodes, ports) : ports;
return typeof nodes === 'number' ? nodes : ports;
}
get loopLabelX(): number {
@ -294,22 +300,26 @@ export class CustomConnectionComponent {
}
get strokeColor(): string {
if (this.isSelected) return '#f97316';
if (this.isSelected) {
return this.isResourceLink ? '#0f9f8d' : this.isDependencyConnection ? '#4f46e5' : '#f97316';
}
if (this.isBiasRoutingChange) return '#b45309';
if (this.isLoopBack) return '#7c3aed';
if (this.isResourceLink) return '#0f766e';
return this.isDependencyConnection ? '#7c8ba1' : '#4682b4';
if (this.isResourceLink) return '#0891b2';
return this.isDependencyConnection ? '#7c3aed' : '#4682b4';
}
get strokeWidth(): number {
if (this.isSelected) return this.isDependencyConnection || this.isResourceLink ? 3.25 : 6;
if (this.isSelected) return this.isDependencyConnection || this.isResourceLink ? 4 : 6;
if (this.isBiasRoutingChange) return 6;
return this.isDependencyConnection || this.isResourceLink ? 2.25 : 5;
}
get strokeDasharray(): string | null {
if (this.isLoopBack) return '12 7';
if (this.isResourceLink) return '2 6';
// A resource is a capability handle, not an ordering dependency: its teal segmented wire is
// intentionally unlike the thin grey dashed dependency wire.
if (this.isResourceLink) return '14 5 2 5';
return this.isDependencyConnection ? '6 6' : null;
}
@ -329,7 +339,12 @@ export class CustomConnectionComponent {
if (this.isReadonly) return;
event.preventDefault();
event.stopPropagation();
this.graphSelection.selectConnection(this.connectionId);
this.graphSelection.selectConnection(this.connectionId, {
sourceId: String(this.data?.source ?? ''),
sourceOutput: String(this.data?.sourceOutput ?? ''),
targetId: String(this.data?.target ?? ''),
targetInput: String(this.data?.targetInput ?? '')
});
}
deleteConnection(event: Event) {

View File

@ -12,6 +12,7 @@ import {
Input,
OnDestroy
} from "@angular/core";
import { GraphSelectionService } from '@services/graph-selection/graph-selection';
@Component({
template: ``,
@ -22,6 +23,7 @@ export class CustomSocket implements AfterViewInit, OnDestroy {
@Input() emit!: any;
@Input() rendered!: any;
private readonly element = inject<ElementRef<HTMLElement>>(ElementRef);
private readonly graphSelection = inject(GraphSelectionService);
private resizeObserver: ResizeObserver | null = null;
private renderFrame: number | null = null;
private side: "input" | "output" = "input";
@ -33,6 +35,7 @@ export class CustomSocket implements AfterViewInit, OnDestroy {
@HostBinding("style.width")
get width() {
if (this.isSelectedEndpoint) return this.kind === "resource" ? "11px" : "12px";
return this.kind === "resource" ? "7px" : "8px";
}
@ -45,7 +48,13 @@ export class CustomSocket implements AfterViewInit, OnDestroy {
@HostBinding("style.borderRadius") get borderRadius() {
return this.kind === "resource" ? "4px" : "999px";
}
@HostBinding("style.border") border = "1px solid rgba(255,255,255,0.9)";
@HostBinding("style.border") get border() {
return this.isSelectedEndpoint ? "2px solid #fff" : "1px solid rgba(255,255,255,0.9)";
}
@HostBinding('attr.data-connection-endpoint-selected') get selectedEndpointAttribute() {
return this.isSelectedEndpoint || null;
}
@HostBinding("style.cursor") get cursor() {
return this.data?.__readonly === true ? "default" : "crosshair";
}
@ -56,6 +65,13 @@ export class CustomSocket implements AfterViewInit, OnDestroy {
@HostBinding("style.background")
get bg() {
if (this.isSelectedEndpoint) {
return this.kind === 'resource'
? 'linear-gradient(145deg, #5eead4 0%, #0f9f8d 100%)'
: this.kind === 'dependency'
? 'linear-gradient(145deg, #a5b4fc 0%, #4f46e5 100%)'
: 'linear-gradient(145deg, #fdba74 0%, #ea580c 100%)';
}
if (this.kind === "resource") {
return "linear-gradient(180deg, #5eead4 0%, #0f766e 100%)";
}
@ -71,6 +87,12 @@ export class CustomSocket implements AfterViewInit, OnDestroy {
@HostBinding("style.boxShadow")
get sh() {
if (this.isSelectedEndpoint) {
const color = this.kind === 'resource' ? 'rgba(15,159,141,0.78)'
: this.kind === 'dependency' ? 'rgba(79,70,229,0.72)'
: 'rgba(234,88,12,0.72)';
return `0 0 0 4px ${color}, 0 5px 14px ${color}`;
}
if (this.kind === "resource") {
return "0 1px 4px rgba(15,118,110,0.32), 0 0 0 1px rgba(13,148,136,0.42)";
}
@ -92,6 +114,18 @@ export class CustomSocket implements AfterViewInit, OnDestroy {
return kind === "dependency" || kind === "resource" ? kind : "data";
}
/** True for either end of the selected data, dependency or resource connection. */
get isSelectedEndpoint(): boolean {
const selected = this.graphSelection.selectedConnection();
if (!selected) return false;
const nodeId = this.data?.nodeId ?? this.data?.payload?.nodeId;
const key = this.data?.key ?? this.data?.payload?.key;
if (nodeId == null || key == null) return false;
return this.side === 'output'
? String(nodeId) === selected.sourceId && String(key) === selected.sourceOutput
: String(nodeId) === selected.targetId && String(key) === selected.targetInput;
}
ngOnChanges(): void {
this.side = this.resolveSocketSide();
this.kind = this.resolveSocketKind();

View File

@ -1082,6 +1082,8 @@
border-color: #14b8a6;
background: linear-gradient(135deg, #ccfbf1 0%, #99f6e4 100%);
box-shadow: inset 0 0 0 1px rgba(13, 148, 136, 0.14);
letter-spacing: 0.04em;
text-transform: none;
}
.llm-pill-resource--connected .bi {

View File

@ -231,7 +231,7 @@
refComponent
class="llm-pill llm-pill-input llm-pill-resource"
[class.llm-pill-resource--connected]="resourceLinkIsConnected"
[attr.title]="resourceLinkIsConnected ? 'Resource connected — drag another Resource here to replace it' : 'Drag a Resource here'"
[attr.title]="resourceLinkIsConnected ? 'Connected to ' + connectedResourceName + ' — drag another Resource here to replace it' : 'Drag a Resource here'"
[data]="{
type: 'socket',
side: 'input',
@ -243,7 +243,7 @@
[emit]="emit">
@if (resourceLinkIsConnected) {
<i class="bi bi-link-45deg" aria-hidden="true"></i>
Resource connected
Connected to {{ connectedResourceName }}
} @else {
Resource
}

View File

@ -283,6 +283,16 @@ export class GenericNodeComponent implements OnDestroy {
return typeof value === 'string' && value.trim().length > 0;
}
/** The Resource node named by the handle, retained by the canvas when the link is drawn. */
get connectedResourceName(): string {
const nodeData = this.data?.data as Record<string, any> | undefined;
const name = nodeData?.['__connectedResourceName'];
if (typeof name === 'string' && name.trim()) return name.trim();
const field = nodeData?.['__resourceRefField'];
const id = typeof field === 'string' ? nodeData?.['specificConfiguration']?.[field] : null;
return typeof id === 'string' && id.trim() ? id : 'Resource';
}
get dependantOutput(): RenderedSocketPort | null {
const output = this.data?.outputs?.[FLOW_DEPENDANT_PORT_KEY];
return output
@ -1054,6 +1064,9 @@ export class GenericNodeComponent implements OnDestroy {
}
private isStructuralField(path: string): boolean {
// Storage operation settings are supplied by a dynamic schema. Their nested properties are not
// present in the static root schema, but a SQL statement changes placeholder input ports.
if (this.blockType === 'StorageOperation' && path.startsWith('settings.')) return true;
return getSchemaPathUiMeta(this.blockSchema, path).structural;
}

View File

@ -885,6 +885,8 @@ button.llm-node-bias-summary:focus-visible {
border-color: #14b8a6;
background: linear-gradient(135deg, #ccfbf1 0%, #99f6e4 100%);
box-shadow: inset 0 0 0 1px rgba(13, 148, 136, 0.14);
letter-spacing: 0.04em;
text-transform: none;
}
.llm-pill-resource--connected .bi {

View File

@ -259,7 +259,7 @@
refComponent
class="llm-pill llm-pill-input llm-pill-resource"
[class.llm-pill-resource--connected]="storageLinkIsConnected"
[attr.title]="storageLinkIsConnected ? 'Resource connected' : 'Resource'"
[attr.title]="storageLinkIsConnected ? 'Connected to ' + connectedResourceName : 'Resource'"
[data]="{
type: 'socket',
side: 'input',
@ -271,7 +271,7 @@
[emit]="emit">
@if (storageLinkIsConnected) {
<i class="bi bi-link-45deg" aria-hidden="true"></i>
Resource connected
Connected to {{ connectedResourceName }}
} @else {
Resource
}

View File

@ -181,6 +181,16 @@ export class TaskStepNodeComponent {
return typeof value === 'string' && value.trim().length > 0;
}
/** The Resource node named by the handle, retained by the canvas when the link is drawn. */
get connectedResourceName(): string {
const nodeData = this.data?.data as Record<string, any> | undefined;
const name = nodeData?.['__connectedResourceName'];
if (typeof name === 'string' && name.trim()) return name.trim();
const field = nodeData?.['__resourceRefField'];
const id = typeof field === 'string' ? nodeData?.['specificConfiguration']?.[field] : null;
return typeof id === 'string' && id.trim() ? id : 'Resource';
}
get dependantOutput(): { key: string; socket: ClassicPreset.Socket } | null {
const output = this.data?.outputs?.[FLOW_DEPENDANT_PORT_KEY];
return output

View File

@ -44,6 +44,7 @@ describe('ReteEditor', () => {
deleteConnectionRequestTick: vi.fn(),
loopLimitRequest: vi.fn().mockReturnValue(null),
selectedConnectionId: vi.fn().mockReturnValue(null),
selectedConnection: vi.fn().mockReturnValue(null),
clearConnectionSelection: vi.fn()
}
}
@ -70,6 +71,27 @@ describe('ReteEditor', () => {
expect(component).toBeTruthy();
});
it('brings both endpoints of a selected connection above unrelated nodes', async () => {
const source = document.createElement('div');
const target = document.createElement('div');
const reorder = vi.fn().mockResolvedValue(undefined);
const rete = {
area: {
nodeViews: new Map([
['source', { element: source }],
['target', { element: target }]
]),
area: { content: { reorder } }
}
} as any;
(component as any).rete = rete;
await (component as any).bringConnectionEndpointsToFront(rete, 'source', 'target');
expect(reorder).toHaveBeenNthCalledWith(1, source, null);
expect(reorder).toHaveBeenNthCalledWith(2, target, null);
});
it('does not render the dormant swimlane UI', () => {
fixture.componentRef.setInput('flowData', {
blocks: [

View File

@ -53,6 +53,12 @@ export class ReteEditor implements OnChanges, OnDestroy {
if (!request || !rete || isReadonly) return;
void this.applyLoopLimit(rete, request.connectionId, request.maxIterations);
});
effect(() => {
const selected = this.graphSelection.selectedConnection();
const rete = untracked(() => this.rete);
if (!selected || !rete) return;
void this.bringConnectionEndpointsToFront(rete, selected.sourceId, selected.targetId);
});
}
private async applyLoopLimit(rete: ReteEditorInstance, connectionId: string, maxIterations: number | null) {
@ -60,6 +66,16 @@ export class ReteEditor implements OnChanges, OnDestroy {
this.flowState.updateData(exportGraph(rete.editor), { structural: true });
}
/** Keep both endpoint cards above unrelated nodes while their selected wire is in front. */
private async bringConnectionEndpointsToFront(rete: ReteEditorInstance, sourceId: string, targetId: string) {
for (const nodeId of new Set([sourceId, targetId])) {
if (this.rete !== rete) return;
const view = rete.area.nodeViews.get(nodeId);
if (!view) continue;
await rete.area.area.content.reorder(view.element, null);
}
}
readonly container = viewChild.required<ElementRef>('editor');
readonly shell = viewChild.required<ElementRef<HTMLElement>>('shell');

View File

@ -6,7 +6,7 @@ import { DEFAULT_NODE_CAPABILITIES, RESOURCE_NODE_CAPABILITIES } from '@models/f
import { HFNode } from '@models/nodes';
import { vi } from 'vitest';
import { isResourceNode, resolveNodeCapabilities, ReteRuntimeContext } from './rete-editor';
import { isResourceNode, refreshLoopMarkers, resolveNodeCapabilities, ReteRuntimeContext, syncResourceLinkPresentation } from './rete-editor';
function makeNode(typeName: string, nodeFamily: 'block' | 'container' | 'resource'): HFNode {
return { data: { typeName, nodeFamily } } as unknown as HFNode;
@ -87,7 +87,88 @@ describe('isResourceNode', () => {
});
});
describe('loop route placement', () => {
it('starts 48px above its highest endpoint, irrespective of a higher node between them', async () => {
const start = { id: 'start', data: { typeName: 'Start', nodeFamily: 'block' } } as any;
const router = { id: 'router', data: { typeName: 'Router', nodeFamily: 'block' } } as any;
const intermediate = { id: 'intermediate', data: { typeName: 'Step', nodeFamily: 'block' } } as any;
const forward = { id: 'forward', source: 'start', sourceOutput: 'out', target: 'router', targetInput: 'in' } as any;
const loop = { id: 'loop', source: 'router', sourceOutput: 'out', target: 'start', targetInput: 'in', loop: {} } as any;
const editor = {
getNodes: () => [start, router, intermediate],
getConnections: () => [forward, loop]
} as any;
const area = {
nodeViews: new Map([
['start', { position: { x: 0, y: 500 }, element: { offsetWidth: 100 } }],
['router', { position: { x: 400, y: 400 }, element: { offsetWidth: 100 } }],
['intermediate', { position: { x: 200, y: 100 }, element: { offsetWidth: 100 } }]
]),
update: vi.fn().mockResolvedValue(undefined)
} as any;
const runtime = makeRuntime({
blocksService: {
peekBlockType: vi.fn().mockImplementation((type: string) =>
type === 'Router' ? { capabilities: { ...DEFAULT_NODE_CAPABILITIES, routesExclusively: true } } : null)
} as any
});
await refreshLoopMarkers(editor, area, runtime);
expect(loop.__loopBack).toBe(true);
expect(loop.__loopTop).toBe(352);
area.nodeViews.get('intermediate').position.y = 0;
await refreshLoopMarkers(editor, area, runtime);
expect(loop.__loopTop).toBe(352);
area.nodeViews.get('router').position.y = 300;
await refreshLoopMarkers(editor, area, runtime);
expect(loop.__loopTop).toBe(252);
});
});
describe('the link between a resource and a step that uses it', () => {
it('replaces an S3 operation choice with PostgreSQL choices when its resource changes', async () => {
const { NodeEditor, ClassicPreset } = await import('rete');
const { FLOW_RESOURCE_LINK_PORT_KEY, FLOW_RESOURCE_PORT_KEY } = await import('@models/flow');
const editor = new NodeEditor<any>();
const socket = new ClassicPreset.Socket('__FLOW_RESOURCE__');
const s3 = new ClassicPreset.Node('S3') as any;
s3.data = { id: 's3', typeName: 'S3Storage', nodeFamily: 'resource' };
s3.addOutput(FLOW_RESOURCE_PORT_KEY, new ClassicPreset.Output(socket));
const postgres = new ClassicPreset.Node('PostgreSQL') as any;
postgres.data = { id: 'postgres', name: 'Analytics database', typeName: 'PostgresStorage', nodeFamily: 'resource' };
postgres.addOutput(FLOW_RESOURCE_PORT_KEY, new ClassicPreset.Output(socket));
const operation = new ClassicPreset.Node('Operation') as any;
operation.data = {
id: 'operation', typeName: 'StorageOperation', nodeFamily: 'block', __resourceRefField: 'storageNode',
specificConfiguration: { storageNode: 's3', resourceType: 'S3Storage', operationId: 'GET_OBJECT', settings: { key: 'a' } }
};
operation.addInput(FLOW_RESOURCE_LINK_PORT_KEY, new ClassicPreset.Input(socket, undefined, true));
await editor.addNode(s3);
await editor.addNode(postgres);
await editor.addNode(operation);
const runtime = makeRuntime({
blocksService: { peekBlockType: vi.fn().mockReturnValue(null) } as any
});
const area = { update: vi.fn().mockResolvedValue(undefined) } as any;
const oldLink = new ClassicPreset.Connection(s3, FLOW_RESOURCE_PORT_KEY, operation, FLOW_RESOURCE_LINK_PORT_KEY);
await editor.addConnection(oldLink);
await editor.removeConnection(oldLink.id);
await syncResourceLinkPresentation(editor, area, runtime, oldLink as any);
const newLink = new ClassicPreset.Connection(postgres, FLOW_RESOURCE_PORT_KEY, operation, FLOW_RESOURCE_LINK_PORT_KEY);
await editor.addConnection(newLink);
await syncResourceLinkPresentation(editor, area, runtime, newLink as any);
expect(operation.data.specificConfiguration).toMatchObject({
storageNode: 'postgres', resourceType: 'PostgresStorage', operationId: null, settings: {}
});
expect(operation.data.__connectedResourceName).toBe('Analytics database');
expect(area.update).toHaveBeenCalledWith('node', operation.id);
});
it('is what fills the step\'s resource field, and empties it when the link is gone', async () => {
const { NodeEditor, ClassicPreset } = await import('rete');
const { exportGraph } = await import('./rete-editor');

View File

@ -36,6 +36,7 @@ import {
import { BlocksService } from "@services/blocks/blocks";
import { ContainersService } from "@services/containers/containers";
import { ResourcesService } from "@services/resources/resources";
import { GraphSelectionService } from "@services/graph-selection/graph-selection";
import { EditorStateHolder } from "@stores/flow-editor";
import { ContainerNodeComponent } from "@shared/nodes/container-node/container-node";
import { GenericNodeComponent } from "@shared/nodes/generic-node/generic-node";
@ -107,6 +108,7 @@ export async function createEditor(
const area = new AreaPlugin<HFSchemes, AreaExtra>(container);
const connection = new ConnectionPlugin<HFSchemes, AreaExtra>();
const render = new AngularPlugin<HFSchemes, AreaExtra>({ injector });
const graphSelection = injector.get(GraphSelectionService);
const nodeView = options?.nodeView ?? "editor";
const readonly = options?.readonly === true;
const programmaticTranslations = new Set<string>();
@ -223,8 +225,18 @@ export async function createEditor(
});
area.addPipe((context: any) => {
// The connection renderer raises only the selected wire. Clear it before dragging an unrelated
// card, but keep it when the picked card is one of the wire's own endpoints.
if (context?.type === 'nodepicked') {
const selected = graphSelection.selectedConnection();
const nodeId = String(context.data?.id ?? '');
if (!selected || (nodeId !== selected.sourceId && nodeId !== selected.targetId)) {
graphSelection.clearConnectionSelection();
}
}
if (context?.type === 'nodetranslated') {
// Any node moved can be one the way back has to clear, not only one of its two ends.
// A loop follows only its own endpoints. A card merely crossed by the wire must not make the
// wire move away, while moving either endpoint keeps the wire 48px above that endpoint.
for (const connection of editor.getConnections() as LoopAwareConnection[]) {
if (!connection.__loopBack) continue;
const top = loopTop(area, connection);
@ -846,7 +858,7 @@ async function replaceResourceLink(editor: NodeEditor<HFSchemes>, stepNodeId: st
}
/** Keep a node's visible Resource state in step with the one allowed resource connection. */
async function syncResourceLinkPresentation(
export async function syncResourceLinkPresentation(
editor: NodeEditor<HFSchemes>,
area: AreaPlugin<HFSchemes, AreaExtra>,
runtime: ReteRuntimeContext,
@ -855,20 +867,39 @@ async function syncResourceLinkPresentation(
if (getGraphConnectionKind(connection.sourceOutput, connection.targetInput) !== 'resource') return;
const target = editor.getNode(connection.target) as HFNode | undefined;
const reference = resourceRefOf(runtime, target);
if (!target?.data || !reference) return;
const targetData = target?.data as Record<string, any> | undefined;
// A block rebuilt after a structural setting update may no longer have its descriptor in the
// client cache, but addBlockToEditor has already recorded the field that owns this link.
const rememberedField = typeof targetData?.['__resourceRefField'] === 'string'
? targetData['__resourceRefField']
: null;
const reference = resourceRefOf(runtime, target)
?? (rememberedField ? { field: rememberedField, resourceKind: '' } : null);
if (!target || !targetData || !reference) return;
const activeLink = editor.getConnections().find((candidate) =>
candidate.target === target.id && candidate.targetInput === FLOW_RESOURCE_LINK_PORT_KEY);
const source = activeLink ? editor.getNode(activeLink.source) as HFNode | undefined : undefined;
const resourceId = typeof source?.data?.id === 'string' ? source.data.id : null;
const targetData = target.data as Record<string, any>;
const resourceType = typeof source?.data?.typeName === 'string' && source.data.typeName.trim()
? source.data.typeName
: null;
const resourceName = typeof source?.data?.name === 'string' && source.data.name.trim()
? source.data.name
: typeof source?.data?.specificConfiguration?.name === 'string' && source.data.specificConfiguration.name.trim()
? source.data.specificConfiguration.name
: resourceId;
const configuration = targetData['specificConfiguration'] as Record<string, unknown> | undefined;
if (configuration?.[reference.field] === resourceId) return;
const resourceChanged = configuration?.[reference.field] !== resourceId;
const nameChanged = targetData['__connectedResourceName'] !== resourceName;
if (!resourceChanged && configuration?.['resourceType'] === resourceType && !nameChanged) return;
targetData['__connectedResourceName'] = resourceName;
targetData['specificConfiguration'] = {
...(configuration ?? {}),
[reference.field]: resourceId
[reference.field]: resourceId,
...(reference.field === 'storageNode' ? { resourceType } : {}),
...(resourceChanged && reference.field === 'storageNode' ? { operationId: null, settings: {} } : {})
};
await area.update('node', target.id);
}
@ -966,22 +997,15 @@ export async function refreshLoopMarkers(
}
/**
* Above the tops of the nodes at both ends. Their tops, not their bottoms: a node's height changes
* as it is expanded or edited, its position only when it is moved.
* A loop runs 48px above its highest endpoint. Nodes in between are deliberately ignored, so they
* may be moved across the wire without causing it to move.
*/
function loopTop(area: AreaPlugin<HFSchemes, AreaExtra>, connection: LoopAwareConnection): number | undefined {
const ends = [connection.source, connection.target]
.map((id) => area.nodeViews.get(id))
.filter((view): view is NonNullable<typeof view> => view != null);
if (!ends.length) return undefined;
// The way back runs across everything between its two ends, so it has to clear the tallest of
// those nodes too - the loop's own steps usually sit there - not only the two it connects.
const left = Math.min(...ends.map((view) => view.position.x));
const right = Math.max(...ends.map((view) => view.position.x + view.element.offsetWidth));
const tops = [...area.nodeViews.values()]
.filter((view) => view.position.x + view.element.offsetWidth >= left && view.position.x <= right)
.map((view) => view.position.y);
return Math.min(...tops, ...ends.map((view) => view.position.y)) - 48;
return Math.min(...ends.map((view) => view.position.y)) - 48;
}
/**