feat(rack): add shift-click context modal for connections with color and edge type configurability

This commit is contained in:
2026-03-22 21:35:10 -05:00
parent 72918bd87a
commit f1c1efd8d3
10 changed files with 277 additions and 17 deletions

View File

@@ -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<null>(`/connections/${id}`),
deleteByPorts: (p1: string, p2: string) => del<null>(`/connections/ports/${p1}/${p2}`),
};

View File

@@ -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 (
<Modal open={open} onClose={() => !loading && onClose()} title="Edit Connection">
<div className="flex flex-col gap-6">
<p className="text-sm text-slate-400">
Customize the cable style and color.
</p>
<form id="connection-form" onSubmit={handleSubmit} className="flex flex-col gap-5">
{/* Label Name */}
<div className="flex flex-col gap-1.5">
<label className="text-sm font-semibold text-slate-300">Cable Label (Optional)</label>
<input
type="text"
value={label}
onChange={(e) => 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"
/>
</div>
{/* Color Picker */}
<div className="flex flex-col gap-2">
<label className="text-sm font-semibold text-slate-300">Cable Color</label>
<div className="flex flex-wrap gap-2">
{PRESET_COLORS.map((c) => (
<button
key={c}
type="button"
onClick={() => setColor(c)}
style={{ backgroundColor: c }}
className={`w-8 h-8 rounded-full border-2 transition-all hover:scale-110 ${
color === c ? 'border-white scale-110 shadow-lg' : 'border-transparent'
}`}
aria-label={`Select color ${c}`}
/>
))}
<div className="relative w-8 h-8 rounded-full overflow-hidden border-2 border-slate-700">
<input
type="color"
value={color}
onChange={(e) => setColor(e.target.value)}
className="absolute -top-2 -left-2 w-12 h-12 cursor-pointer"
title="Custom color"
/>
</div>
</div>
</div>
{/* Edge Style */}
<div className="flex flex-col gap-2">
<label className="text-sm font-semibold text-slate-300">Curve Style</label>
<div className="grid grid-cols-3 gap-2">
{EDGE_STYLES.map((style) => (
<button
key={style.value}
type="button"
onClick={() => setEdgeType(style.value)}
className={`px-3 py-2 rounded-md border text-xs font-medium transition-all ${
edgeType === style.value
? 'bg-blue-500/10 border-blue-500 text-blue-400'
: 'bg-slate-900 border-slate-700 text-slate-400 hover:border-slate-500 hover:text-slate-200'
}`}
>
{style.label}
</button>
))}
</div>
</div>
</form>
<div className="flex items-center justify-between pt-4 border-t border-slate-800">
<Button type="button" variant="ghost" className="text-red-400 hover:text-red-300" onClick={handleDelete} disabled={loading}>
Delete Connection
</Button>
<div className="flex gap-2">
<Button type="button" variant="ghost" onClick={onClose} disabled={loading}>
Cancel
</Button>
<Button type="submit" form="connection-form" variant="primary" loading={loading}>
Save Changes
</Button>
</div>
</div>
</div>
</Modal>
);
}

View File

@@ -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<Record<string, { x: number; y: number }>>({});
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 (
<g key={conn.id} className="connection-group">
<path
d={`M ${start.x} ${start.y} C ${start.x + curvature} ${start.y}, ${end.x - curvature} ${end.y}, ${end.x} ${end.y}`}
d={d}
stroke={conn.color || '#3b82f6'}
strokeWidth="2.5"
fill="none"
@@ -136,11 +145,17 @@ export function ConnectionLayer() {
/>
{/* Thicker transparent helper for easier identification if we ever add hover interactions */}
<path
d={`M ${start.x} ${start.y} C ${start.x + curvature} ${start.y}, ${end.x - curvature} ${end.y}, ${end.x} ${end.y}`}
d={d}
stroke="transparent"
strokeWidth="10"
fill="none"
className="pointer-events-auto cursor-help"
className="pointer-events-auto cursor-pointer"
onClick={(e) => {
if (e.shiftKey) {
e.stopPropagation();
setActiveConfigConnectionId(conn.id);
}
}}
/>
</g>
);

View File

@@ -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<HTMLDivElement>(null);
// Drag state
@@ -304,6 +305,14 @@ export function RackPlanner() {
onClose={() => setActiveConfigPortId(null)}
/>
)}
{activeConfigConnectionId && (
<ConnectionConfigModal
open={!!activeConfigConnectionId}
connectionId={activeConfigConnectionId}
onClose={() => setActiveConfigConnectionId(null)}
/>
)}
</DndContext>
);
}

View File

@@ -23,10 +23,14 @@ interface RackState {
cablingFromPortId: string | null;
setCablingFromPortId: (id: string | null) => void;
createConnection: (fromPortId: string, toPortId: string) => Promise<void>;
updateConnection: (id: string, data: Partial<{ color: string; label: string; edgeType: string }>) => Promise<void>;
deleteConnection: (id: string) => Promise<void>;
// 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<RackState>((set, get) => ({
@@ -132,6 +136,15 @@ export const useRackStore = create<RackState>((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 }),
}));

View File

@@ -67,6 +67,7 @@ export interface Connection {
toPortId: string;
color?: string;
label?: string;
edgeType?: string;
createdAt: string;
}

View File

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

View File

@@ -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])

View File

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

View File

@@ -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: {