merge: dynamic storage operations editor
This commit is contained in:
commit
5dc537146d
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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: [
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
Loading…
Reference in New Issue