From f1c1efd8d358c9de3e23495c2ee96216b5736ecc Mon Sep 17 00:00:00 2001 From: jason Date: Sun, 22 Mar 2026 21:35:10 -0500 Subject: [PATCH] feat(rack): add shift-click context modal for connections with color and edge type configurability --- client/src/api/client.ts | 4 +- .../modals/ConnectionConfigModal.tsx | 181 ++++++++++++++++++ .../src/components/rack/ConnectionLayer.tsx | 39 ++-- client/src/components/rack/RackPlanner.tsx | 11 +- client/src/store/useRackStore.ts | 13 ++ client/src/types/index.ts | 1 + .../migration.sql | 20 ++ prisma/schema.prisma | 1 + server/routes/connections.ts | 15 +- server/services/connectionService.ts | 9 +- 10 files changed, 277 insertions(+), 17 deletions(-) create mode 100644 client/src/components/modals/ConnectionConfigModal.tsx create mode 100644 prisma/migrations/20260323023033_add_edge_type_to_connection/migration.sql diff --git a/client/src/api/client.ts b/client/src/api/client.ts index eff3629..52393ce 100644 --- a/client/src/api/client.ts +++ b/client/src/api/client.ts @@ -200,8 +200,10 @@ const edges = { // ---- Connections ---- const connections = { - create: (data: { fromPortId: string; toPortId: string; color?: string; label?: string }) => + create: (data: { fromPortId: string; toPortId: string; color?: string; label?: string; edgeType?: string }) => post<{ id: string }>('/connections', data), + update: (id: string, data: Partial<{ color: string; label: string; edgeType: string }>) => + put<{ id: string }>(`/connections/${id}`, data), delete: (id: string) => del(`/connections/${id}`), deleteByPorts: (p1: string, p2: string) => del(`/connections/ports/${p1}/${p2}`), }; diff --git a/client/src/components/modals/ConnectionConfigModal.tsx b/client/src/components/modals/ConnectionConfigModal.tsx new file mode 100644 index 0000000..6cb196e --- /dev/null +++ b/client/src/components/modals/ConnectionConfigModal.tsx @@ -0,0 +1,181 @@ +import { useState, useEffect, useMemo, type FormEvent } from 'react'; +import { toast } from 'sonner'; +import { Modal } from '../ui/Modal'; +import { Button } from '../ui/Button'; +import { useRackStore } from '../../store/useRackStore'; +import type { Connection } from '../../types'; + +interface ConnectionConfigModalProps { + connectionId: string | null; + open: boolean; + onClose: () => void; +} + +const EDGE_STYLES = [ + { value: 'bezier', label: 'Curved (Bezier)' }, + { value: 'straight', label: 'Straight Line' }, + { value: 'step', label: 'Stepped / Orthogonal' }, +]; + +const PRESET_COLORS = [ + '#3b82f6', // Blue + '#10b981', // Emerald + '#8b5cf6', // Violet + '#ef4444', // Red + '#f59e0b', // Amber + '#ec4899', // Pink + '#64748b', // Slate + '#ffffff', // White +]; + +export function ConnectionConfigModal({ connectionId, open, onClose }: ConnectionConfigModalProps) { + const { racks, setCablingFromPortId, updateConnection, deleteConnection } = useRackStore(); + const [loading, setLoading] = useState(false); + + // Synchronously find the connection from the global store + const connection = useMemo(() => { + if (!connectionId) return null; + for (const rack of racks) { + for (const mod of rack.modules) { + for (const port of mod.ports) { + const found = port.sourceConnections?.find((c) => c.id === connectionId); + if (found) return found; + } + } + } + return null; + }, [racks, connectionId]); + + // Form state + const [color, setColor] = useState('#3b82f6'); + const [edgeType, setEdgeType] = useState('bezier'); + const [label, setLabel] = useState(''); + + // Reset form state when connection is found or changed + useEffect(() => { + if (connection && open) { + setColor(connection.color || '#3b82f6'); + setEdgeType(connection.edgeType || 'bezier'); + setLabel(connection.label || ''); + } + }, [connection, open]); + + async function handleSubmit(e: FormEvent) { + e.preventDefault(); + if (!connectionId) return; + setLoading(true); + try { + await updateConnection(connectionId, { color, edgeType, label }); + toast.success('Connection updated'); + onClose(); + } catch (err) { + toast.error(err instanceof Error ? err.message : 'Update failed'); + } finally { + setLoading(false); + } + } + + async function handleDelete() { + if (!connectionId) return; + if (!confirm('Are you sure you want to remove this connection?')) return; + setLoading(true); + try { + await deleteConnection(connectionId); + toast.success('Connection removed'); + onClose(); + } catch (err) { + toast.error(err instanceof Error ? err.message : 'Delete failed'); + } finally { + setLoading(false); + } + } + + if (!open || !connection) return null; + + return ( + !loading && onClose()} title="Edit Connection"> +
+

+ Customize the cable style and color. +

+ +
+ {/* Label Name */} +
+ + setLabel(e.target.value)} + placeholder="e.g. Uplink to Core" + className="px-3 py-2 rounded-md bg-slate-900 border border-slate-700 text-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 transition-colors" + /> +
+ + {/* Color Picker */} +
+ +
+ {PRESET_COLORS.map((c) => ( +
+
+ + {/* Edge Style */} +
+ +
+ {EDGE_STYLES.map((style) => ( + + ))} +
+
+
+ +
+ +
+ + +
+
+
+
+ ); +} diff --git a/client/src/components/rack/ConnectionLayer.tsx b/client/src/components/rack/ConnectionLayer.tsx index 0ef04d2..831c40f 100644 --- a/client/src/components/rack/ConnectionLayer.tsx +++ b/client/src/components/rack/ConnectionLayer.tsx @@ -1,8 +1,9 @@ import { useEffect, useState, useMemo, useCallback } from 'react'; import { useRackStore } from '../../store/useRackStore'; +import { cn } from '../../lib/utils'; export function ConnectionLayer() { - const { racks, cablingFromPortId } = useRackStore(); + const { racks, cablingFromPortId, setActiveConfigConnectionId } = useRackStore(); const [coords, setCoords] = useState>({}); const [mousePos, setMousePos] = useState({ x: 0, y: 0 }); @@ -78,7 +79,7 @@ export function ConnectionLayer() { }, [cablingFromPortId]); const connections = useMemo(() => { - const conns: { id: string; from: string; to: string; color?: string; fromRackId: string; toRackId: string }[] = []; + const conns: { id: string; from: string; to: string; color?: string; edgeType?: string }[] = []; racks.forEach((rack) => { rack.modules.forEach((mod) => { mod.ports.forEach((port) => { @@ -88,8 +89,7 @@ export function ConnectionLayer() { from: c.fromPortId, to: c.toPortId, color: c.color, - fromRackId: rack.id, - toRackId: '' // We don't easily know the destination rack without searching + edgeType: c.edgeType, }); }); }); @@ -118,16 +118,25 @@ export function ConnectionLayer() { const end = coords[conn.to]; if (!start || !end) return null; - // Calculate a slight curve. If ports are close, use a tighter curve. - const dx = Math.abs(end.x - start.x); - const dy = Math.abs(end.y - start.y); - const distance = Math.sqrt(dx*dx + dy*dy); - const curvature = Math.min(100, distance / 3); + let d = ''; + if (conn.edgeType === 'straight') { + d = `M ${start.x} ${start.y} L ${end.x} ${end.y}`; + } else if (conn.edgeType === 'step') { + const midX = start.x + (end.x - start.x) / 2; + d = `M ${start.x} ${start.y} L ${midX} ${start.y} L ${midX} ${end.y} L ${end.x} ${end.y}`; + } else { + // default bezier + const dx = Math.abs(end.x - start.x); + const dy = Math.abs(end.y - start.y); + const distance = Math.sqrt(dx*dx + dy*dy); + const curvature = Math.min(100, distance / 3); + d = `M ${start.x} ${start.y} C ${start.x + curvature} ${start.y}, ${end.x - curvature} ${end.y}, ${end.x} ${end.y}`; + } return ( {/* Thicker transparent helper for easier identification if we ever add hover interactions */} { + if (e.shiftKey) { + e.stopPropagation(); + setActiveConfigConnectionId(conn.id); + } + }} /> ); diff --git a/client/src/components/rack/RackPlanner.tsx b/client/src/components/rack/RackPlanner.tsx index f371dbb..acad05e 100644 --- a/client/src/components/rack/RackPlanner.tsx +++ b/client/src/components/rack/RackPlanner.tsx @@ -19,6 +19,7 @@ import { DevicePalette } from './DevicePalette'; import { ConnectionLayer } from './ConnectionLayer'; import { AddModuleModal } from '../modals/AddModuleModal'; import { PortConfigModal } from '../modals/PortConfigModal'; +import { ConnectionConfigModal } from '../modals/ConnectionConfigModal'; import { RackSkeleton } from '../ui/Skeleton'; import type { ModuleType } from '../../types'; import { MODULE_TYPE_COLORS, MODULE_TYPE_LABELS } from '../../lib/constants'; @@ -86,7 +87,7 @@ const POINTER_SENSOR_OPTIONS = { }; export function RackPlanner() { - const { racks, loading, fetchRacks, moveModule, activeConfigPortId, setActiveConfigPortId } = useRackStore(); + const { racks, loading, fetchRacks, moveModule, activeConfigPortId, setActiveConfigPortId, activeConfigConnectionId, setActiveConfigConnectionId } = useRackStore(); const canvasRef = useRef(null); // Drag state @@ -304,6 +305,14 @@ export function RackPlanner() { onClose={() => setActiveConfigPortId(null)} /> )} + + {activeConfigConnectionId && ( + setActiveConfigConnectionId(null)} + /> + )} ); } diff --git a/client/src/store/useRackStore.ts b/client/src/store/useRackStore.ts index 072503f..095db57 100644 --- a/client/src/store/useRackStore.ts +++ b/client/src/store/useRackStore.ts @@ -23,10 +23,14 @@ interface RackState { cablingFromPortId: string | null; setCablingFromPortId: (id: string | null) => void; createConnection: (fromPortId: string, toPortId: string) => Promise; + updateConnection: (id: string, data: Partial<{ color: string; label: string; edgeType: string }>) => Promise; deleteConnection: (id: string) => Promise; // Port Config Global Modal activeConfigPortId: string | null; setActiveConfigPortId: (id: string | null) => void; + // Connection Config Global Modal + activeConfigConnectionId: string | null; + setActiveConfigConnectionId: (id: string | null) => void; } export const useRackStore = create((set, get) => ({ @@ -132,6 +136,15 @@ export const useRackStore = create((set, get) => ({ set({ racks }); }, + updateConnection: async (id, data) => { + await apiClient.connections.update(id, data); + const racks = await apiClient.racks.list(); + set({ racks }); + }, + activeConfigPortId: null, setActiveConfigPortId: (id) => set({ activeConfigPortId: id }), + + activeConfigConnectionId: null, + setActiveConfigConnectionId: (id) => set({ activeConfigConnectionId: id }), })); diff --git a/client/src/types/index.ts b/client/src/types/index.ts index 6fd19ae..a827765 100644 --- a/client/src/types/index.ts +++ b/client/src/types/index.ts @@ -67,6 +67,7 @@ export interface Connection { toPortId: string; color?: string; label?: string; + edgeType?: string; createdAt: string; } diff --git a/prisma/migrations/20260323023033_add_edge_type_to_connection/migration.sql b/prisma/migrations/20260323023033_add_edge_type_to_connection/migration.sql new file mode 100644 index 0000000..43991dd --- /dev/null +++ b/prisma/migrations/20260323023033_add_edge_type_to_connection/migration.sql @@ -0,0 +1,20 @@ +-- RedefineTables +PRAGMA defer_foreign_keys=ON; +PRAGMA foreign_keys=OFF; +CREATE TABLE "new_Connection" ( + "id" TEXT NOT NULL PRIMARY KEY, + "fromPortId" TEXT NOT NULL, + "toPortId" TEXT NOT NULL, + "color" TEXT, + "label" TEXT, + "edgeType" TEXT NOT NULL DEFAULT 'bezier', + "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "Connection_fromPortId_fkey" FOREIGN KEY ("fromPortId") REFERENCES "Port" ("id") ON DELETE CASCADE ON UPDATE CASCADE, + CONSTRAINT "Connection_toPortId_fkey" FOREIGN KEY ("toPortId") REFERENCES "Port" ("id") ON DELETE CASCADE ON UPDATE CASCADE +); +INSERT INTO "new_Connection" ("color", "createdAt", "fromPortId", "id", "label", "toPortId") SELECT "color", "createdAt", "fromPortId", "id", "label", "toPortId" FROM "Connection"; +DROP TABLE "Connection"; +ALTER TABLE "new_Connection" RENAME TO "Connection"; +CREATE UNIQUE INDEX "Connection_fromPortId_toPortId_key" ON "Connection"("fromPortId", "toPortId"); +PRAGMA foreign_keys=ON; +PRAGMA defer_foreign_keys=OFF; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index ad9be27..ae4a9d5 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -67,6 +67,7 @@ model Connection { toPort Port @relation("TargetPort", fields: [toPortId], references: [id], onDelete: Cascade) color String? // Optional custom cable color label String? // Optional cable label (e.g. "Cable #104") + edgeType String @default("bezier") // bezier | straight | step createdAt DateTime @default(now()) @@unique([fromPortId, toPortId]) diff --git a/server/routes/connections.ts b/server/routes/connections.ts index 6d2ca8a..3aee8f7 100644 --- a/server/routes/connections.ts +++ b/server/routes/connections.ts @@ -6,14 +6,25 @@ const router = Router(); // POST /api/connections router.post('/', async (req, res, next) => { try { - const { fromPortId, toPortId, color, label } = req.body; - const conn = await connService.createConnection({ fromPortId, toPortId, color, label }); + const { fromPortId, toPortId, color, label, edgeType } = req.body; + const conn = await connService.createConnection({ fromPortId, toPortId, color, label, edgeType }); res.status(201).json(conn); } catch (err) { next(err); } }); +// PUT /api/connections/:id +router.put('/:id', async (req, res, next) => { + try { + const { color, label, edgeType } = req.body; + const conn = await connService.updateConnection(req.params.id, { color, label, edgeType }); + res.json(conn); + } catch (err) { + next(err); + } +}); + // DELETE /api/connections/:id router.delete('/:id', async (req, res, next) => { try { diff --git a/server/services/connectionService.ts b/server/services/connectionService.ts index 9e09891..53f0e79 100644 --- a/server/services/connectionService.ts +++ b/server/services/connectionService.ts @@ -1,7 +1,7 @@ import { prisma } from '../lib/prisma'; import { AppError } from '../types/index'; -export async function createConnection(data: { fromPortId: string; toPortId: string; color?: string; label?: string }) { +export async function createConnection(data: { fromPortId: string; toPortId: string; color?: string; label?: string; edgeType?: string }) { // Check if both ports exist const [from, to] = await Promise.all([ prisma.port.findUnique({ where: { id: data.fromPortId } }), @@ -22,6 +22,13 @@ export async function deleteConnection(id: string) { return prisma.connection.delete({ where: { id } }); } +export async function updateConnection(id: string, data: Partial<{ color: string; label: string; edgeType: string }>) { + return prisma.connection.update({ + where: { id }, + data, + }); +} + export async function deleteByPorts(portId1: string, portId2: string) { return prisma.connection.deleteMany({ where: {