feat(rack): add shift-click context modal for connections with color and edge type configurability
This commit is contained in:
@@ -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}`),
|
||||
};
|
||||
|
||||
181
client/src/components/modals/ConnectionConfigModal.tsx
Normal file
181
client/src/components/modals/ConnectionConfigModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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.
|
||||
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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 }),
|
||||
}));
|
||||
|
||||
@@ -67,6 +67,7 @@ export interface Connection {
|
||||
toPortId: string;
|
||||
color?: string;
|
||||
label?: string;
|
||||
edgeType?: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
@@ -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])
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user