refactor(decnet_web/MazeNET): extract useMazeContextMenu
Lift the context-menu builder out of the page shell. The hook owns ctxMenu open/close state and exposes one builder per surface (node / net / edge / canvas); the actual operations come in via callbacks so the page keeps its optimistic-patch logic unchanged. - New MazeNET/useMazeContextMenu.tsx - useMazeContextMenu.test.ts covers menu lifecycle (open/close), node-menu items, observed-entity locking, internet-net delete-disabled, canvas-menu Add subnet/DMZ items, and the edge-menu Remove invocation. - Wiring into MazeNET.tsx lands next.
This commit is contained in:
132
decnet_web/src/components/MazeNET/useMazeContextMenu.test.ts
Normal file
132
decnet_web/src/components/MazeNET/useMazeContextMenu.test.ts
Normal file
@@ -0,0 +1,132 @@
|
||||
/**
|
||||
* @vitest-environment jsdom
|
||||
*/
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { act, renderHook } from '@testing-library/react';
|
||||
import { useMazeContextMenu } from './useMazeContextMenu';
|
||||
import type { Net, MazeNode } from './types';
|
||||
import type { UseTopologyEditor } from './useTopologyEditor';
|
||||
|
||||
const noop = () => {};
|
||||
const noopAsync = async () => {};
|
||||
|
||||
const stubEditor = (): UseTopologyEditor => ({
|
||||
inFlight: 0,
|
||||
addLan: vi.fn(),
|
||||
updateLanRow: vi.fn(),
|
||||
deleteLan: vi.fn(),
|
||||
addDeckyToLan: vi.fn(),
|
||||
updateDecky: vi.fn(),
|
||||
deleteDecky: vi.fn(),
|
||||
attachEdge: vi.fn(),
|
||||
detachEdge: vi.fn(),
|
||||
} as unknown as UseTopologyEditor);
|
||||
|
||||
const fakeMouse = (overrides: Partial<MouseEvent> = {}): React.MouseEvent => ({
|
||||
clientX: 100,
|
||||
clientY: 200,
|
||||
preventDefault: vi.fn(),
|
||||
stopPropagation: vi.fn(),
|
||||
...overrides,
|
||||
} as unknown as React.MouseEvent);
|
||||
|
||||
const subnet: Net = {
|
||||
id: 'lan-1', name: 'lan-corp', label: 'CORP',
|
||||
cidr: '10.0.0.0/24', kind: 'subnet', x: 0, y: 0, w: 300, h: 240,
|
||||
};
|
||||
const internet: Net = {
|
||||
id: 'lan-www', name: 'internet', label: 'INTERNET',
|
||||
cidr: '0.0.0.0/0', kind: 'internet', x: 0, y: 0, w: 300, h: 240,
|
||||
};
|
||||
const decky: MazeNode = {
|
||||
kind: 'decky', id: 'd1', name: 'decoy-01', netId: 'lan-1',
|
||||
archetype: 'workstation', services: ['ssh'], status: 'idle', x: 0, y: 0,
|
||||
};
|
||||
const observed: MazeNode = {
|
||||
kind: 'observed', id: 'obs-1', label: '1.2.3.4', netId: 'lan-www',
|
||||
x: 0, y: 0,
|
||||
};
|
||||
|
||||
const baseArgs = () => ({
|
||||
nets: [subnet, internet],
|
||||
nodes: [decky, observed],
|
||||
services: [
|
||||
{ slug: 'http', name: 'HTTP', proto: 'tcp', port: 80, icon: 'globe', risk: 'medium' as const },
|
||||
],
|
||||
archetypes: [
|
||||
{ slug: 'workstation', name: 'Workstation', services: ['ssh'], icon: 'monitor' },
|
||||
],
|
||||
topologyId: 'topo-1',
|
||||
setSelection: vi.fn(),
|
||||
setNodes: vi.fn(),
|
||||
canvasRef: { current: null as HTMLDivElement | null } as React.RefObject<HTMLDivElement | null>,
|
||||
pan: { x: 0, y: 0 },
|
||||
editor: stubEditor(),
|
||||
flashErr: vi.fn(),
|
||||
onPaletteDrop: vi.fn(),
|
||||
removeNet: vi.fn(),
|
||||
removeNode: vi.fn(),
|
||||
removeEdge: vi.fn(),
|
||||
duplicateNode: vi.fn(),
|
||||
addServiceToNode: vi.fn(),
|
||||
});
|
||||
|
||||
describe('useMazeContextMenu', () => {
|
||||
it('starts with no menu open and clears on closeMenu', () => {
|
||||
const { result } = renderHook(() => useMazeContextMenu(baseArgs()));
|
||||
expect(result.current.ctxMenu).toBeNull();
|
||||
|
||||
act(() => result.current.onNodeContextMenu('d1')(fakeMouse()));
|
||||
expect(result.current.ctxMenu).not.toBeNull();
|
||||
expect(result.current.ctxMenu?.x).toBe(100);
|
||||
expect(result.current.ctxMenu?.items.length).toBeGreaterThan(0);
|
||||
|
||||
act(() => result.current.closeMenu());
|
||||
expect(result.current.ctxMenu).toBeNull();
|
||||
});
|
||||
|
||||
it('node context menu offers add-service / mutate / duplicate / delete', () => {
|
||||
const { result } = renderHook(() => useMazeContextMenu(baseArgs()));
|
||||
act(() => result.current.onNodeContextMenu('d1')(fakeMouse()));
|
||||
const labels = result.current.ctxMenu?.items.map((i) => i.label);
|
||||
expect(labels).toContain('Add service…');
|
||||
expect(labels).toContain('Force mutate');
|
||||
expect(labels).toContain('Duplicate decky');
|
||||
expect(labels).toContain('Delete decky');
|
||||
});
|
||||
|
||||
it('observed entities lock duplicate + delete', () => {
|
||||
const { result } = renderHook(() => useMazeContextMenu(baseArgs()));
|
||||
act(() => result.current.onNodeContextMenu('obs-1')(fakeMouse()));
|
||||
const dup = result.current.ctxMenu?.items.find((i) => i.label === 'Duplicate decky');
|
||||
const del = result.current.ctxMenu?.items.find((i) => i.label === 'Delete decky');
|
||||
expect(dup?.disabled).toBe(true);
|
||||
expect(del?.disabled).toBe(true);
|
||||
});
|
||||
|
||||
it('internet network cannot be deleted', () => {
|
||||
const { result } = renderHook(() => useMazeContextMenu(baseArgs()));
|
||||
act(() => result.current.onNetContextMenu('lan-www')(fakeMouse()));
|
||||
const del = result.current.ctxMenu?.items.find((i) =>
|
||||
typeof i.label === 'string' && i.label.startsWith('Delete'),
|
||||
);
|
||||
expect(del?.disabled).toBe(true);
|
||||
});
|
||||
|
||||
it('canvas context menu provides Add subnet / Add DMZ', () => {
|
||||
const { result } = renderHook(() => useMazeContextMenu(baseArgs()));
|
||||
act(() => result.current.onCanvasContextMenu(fakeMouse()));
|
||||
const labels = result.current.ctxMenu?.items.map((i) => i.label);
|
||||
expect(labels).toEqual(['Add subnet here', 'Add DMZ here']);
|
||||
});
|
||||
|
||||
it('edge context menu invokes removeEdge on click', () => {
|
||||
const args = baseArgs();
|
||||
const { result } = renderHook(() => useMazeContextMenu(args));
|
||||
act(() => result.current.onEdgeContextMenu('e1')(fakeMouse()));
|
||||
const rm = result.current.ctxMenu?.items[0];
|
||||
expect(rm?.label).toBe('Remove edge');
|
||||
rm?.onClick?.();
|
||||
expect(args.removeEdge).toHaveBeenCalledWith('e1');
|
||||
});
|
||||
});
|
||||
215
decnet_web/src/components/MazeNET/useMazeContextMenu.tsx
Normal file
215
decnet_web/src/components/MazeNET/useMazeContextMenu.tsx
Normal file
@@ -0,0 +1,215 @@
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
Copy, Eye, GitMerge, Plus, Server, ShieldAlert, Trash2, Zap,
|
||||
} from '../../icons';
|
||||
import type { Selection } from './Inspector';
|
||||
import type { MenuItem } from './ContextMenu';
|
||||
import type { Net, MazeNode, DeckyNode } from './types';
|
||||
import type { Archetype, ServiceDef } from './data';
|
||||
import type { UseTopologyEditor } from './useTopologyEditor';
|
||||
import type { PaletteDrag } from './useMazeInteraction';
|
||||
|
||||
type CtxMenuState = { x: number; y: number; items: MenuItem[] } | null;
|
||||
|
||||
interface PanLike { x: number; y: number }
|
||||
|
||||
interface Args {
|
||||
nets: Net[];
|
||||
nodes: MazeNode[];
|
||||
services: ServiceDef[];
|
||||
archetypes: Archetype[];
|
||||
topologyId: string;
|
||||
setSelection: (s: Selection) => void;
|
||||
setNodes: React.Dispatch<React.SetStateAction<MazeNode[]>>;
|
||||
canvasRef: React.RefObject<HTMLDivElement | null>;
|
||||
pan: PanLike;
|
||||
editor: UseTopologyEditor;
|
||||
flashErr: (err: unknown, fallback: string) => void;
|
||||
onPaletteDrop: (
|
||||
drag: PaletteDrag,
|
||||
world: { x: number; y: number },
|
||||
overNetId: string | null,
|
||||
overNodeId: string | null,
|
||||
) => void | Promise<void>;
|
||||
removeNet: (id: string) => void | Promise<void>;
|
||||
removeNode: (id: string) => void | Promise<void>;
|
||||
removeEdge: (id: string) => void | Promise<void>;
|
||||
duplicateNode: (id: string) => void | Promise<void>;
|
||||
addServiceToNode: (id: string, slug: string) => void | Promise<void>;
|
||||
}
|
||||
|
||||
const tempIdSuffix = (): string =>
|
||||
Math.random().toString(36).slice(2, 6);
|
||||
|
||||
export interface UseMazeContextMenuResult {
|
||||
ctxMenu: CtxMenuState;
|
||||
closeMenu: () => void;
|
||||
onNodeContextMenu: (id: string) => (e: React.MouseEvent) => void;
|
||||
onNetContextMenu: (id: string) => (e: React.MouseEvent) => void;
|
||||
onEdgeContextMenu: (id: string) => (e: React.MouseEvent) => void;
|
||||
onCanvasContextMenu: (e: React.MouseEvent) => void;
|
||||
}
|
||||
|
||||
/** Pure UI logic for the canvas context menu. Owns the menu's
|
||||
* open/close state and exposes one builder per surface (node /
|
||||
* net / edge / canvas). The actual operations come in as
|
||||
* callbacks so the hook is testable in isolation and the
|
||||
* page shell can keep its own optimistic-patch logic. */
|
||||
export function useMazeContextMenu(args: Args): UseMazeContextMenuResult {
|
||||
const {
|
||||
nets, nodes, services, archetypes, topologyId,
|
||||
setSelection, setNodes, canvasRef, pan,
|
||||
editor, flashErr, onPaletteDrop,
|
||||
removeNet, removeNode, removeEdge, duplicateNode, addServiceToNode,
|
||||
} = args;
|
||||
|
||||
const [ctxMenu, setCtxMenu] = useState<CtxMenuState>(null);
|
||||
const closeMenu = () => setCtxMenu(null);
|
||||
|
||||
// Force-mutate is a no-op against a pending topology (no live containers).
|
||||
// Keep the menu item disabled for now; real hook lands with live-editing polish.
|
||||
const forceMutate = (_id: string) => {
|
||||
flashErr(null, 'force-mutate only applies to deployed topologies');
|
||||
};
|
||||
|
||||
const onNodeContextMenu = (id: string) => (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const node = nodes.find((n) => n.id === id);
|
||||
if (!node) return;
|
||||
setSelection({ type: 'node', id });
|
||||
const isObs = node.kind === 'observed';
|
||||
const isGateway = node.kind === 'decky' && !!node.decky_config?.forwards_l3;
|
||||
const locked = isObs || isGateway;
|
||||
const lockedTitle = isObs
|
||||
? 'observed entity — not a deployed decky'
|
||||
: isGateway ? 'DMZ gateway — pinned to its DMZ network' : undefined;
|
||||
const usedServices = node.kind === 'decky' ? new Set(node.services) : new Set<string>();
|
||||
const serviceSubmenu: MenuItem[] = services
|
||||
.filter((s) => !usedServices.has(s.slug))
|
||||
.slice(0, 16)
|
||||
.map((s) => ({
|
||||
label: `${s.name} · ${s.proto.toUpperCase()}:${s.port}`,
|
||||
disabled: isObs,
|
||||
onClick: () => addServiceToNode(id, s.slug),
|
||||
}));
|
||||
if (serviceSubmenu.length === 0) {
|
||||
serviceSubmenu.push({ label: '(no free services)', disabled: true });
|
||||
}
|
||||
|
||||
setCtxMenu({
|
||||
x: e.clientX, y: e.clientY,
|
||||
items: [
|
||||
{ label: 'Add service…', icon: <Plus size={12} />, disabled: isObs,
|
||||
title: isObs ? 'observed entity — services fixed' : undefined,
|
||||
submenu: serviceSubmenu },
|
||||
{ label: 'Force mutate', icon: <Zap size={12} />, disabled: isObs,
|
||||
onClick: () => forceMutate(id) },
|
||||
{ label: 'Duplicate decky', icon: <Copy size={12} />, disabled: locked,
|
||||
title: lockedTitle, onClick: () => duplicateNode(id) },
|
||||
{ separator: true, label: '' },
|
||||
{ label: 'Delete decky', icon: <Trash2 size={12} />, danger: true,
|
||||
disabled: locked, title: lockedTitle,
|
||||
onClick: () => removeNode(id) },
|
||||
],
|
||||
});
|
||||
};
|
||||
|
||||
const onNetContextMenu = (id: string) => (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const net = nets.find((n) => n.id === id);
|
||||
if (!net) return;
|
||||
setSelection({ type: 'net', id });
|
||||
const archetypeSubmenu: MenuItem[] = archetypes.map((a) => ({
|
||||
label: a.name, icon: <Server size={12} />,
|
||||
onClick: async () => {
|
||||
const name = `decky-${tempIdSuffix()}`;
|
||||
try {
|
||||
const dRes = await editor.addDeckyToLan(
|
||||
topologyId,
|
||||
{ name, services: [...a.services], x: 20, y: 40,
|
||||
decky_config: { archetype: a.slug } },
|
||||
id, net.name,
|
||||
);
|
||||
if (dRes.kind !== 'applied') return;
|
||||
const decky = dRes.data;
|
||||
const node: DeckyNode = {
|
||||
kind: 'decky', id: decky.uuid, netId: id, name: decky.name,
|
||||
archetype: a.slug, services: [...a.services], status: 'idle',
|
||||
x: 20, y: 40,
|
||||
};
|
||||
setNodes((p) => [...p, node]);
|
||||
} catch (err) {
|
||||
flashErr(err, 'create decky failed');
|
||||
}
|
||||
},
|
||||
}));
|
||||
|
||||
setCtxMenu({
|
||||
x: e.clientX, y: e.clientY,
|
||||
items: [
|
||||
{ label: 'Add decky…', icon: <Plus size={12} />, submenu: archetypeSubmenu },
|
||||
{ label: 'Inspect', icon: <Eye size={12} />, onClick: () => setSelection({ type: 'net', id }) },
|
||||
{ separator: true, label: '' },
|
||||
{ label: net.kind === 'dmz' ? 'Delete DMZ' : 'Delete network',
|
||||
icon: <Trash2 size={12} />, danger: true,
|
||||
disabled: net.kind === 'internet',
|
||||
title: net.kind === 'internet' ? 'internet zone cannot be removed' : undefined,
|
||||
onClick: () => removeNet(id) },
|
||||
],
|
||||
});
|
||||
};
|
||||
|
||||
const onEdgeContextMenu = (id: string) => (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setSelection({ type: 'edge', id });
|
||||
setCtxMenu({
|
||||
x: e.clientX, y: e.clientY,
|
||||
items: [
|
||||
{ label: 'Remove edge', icon: <Trash2 size={12} />, danger: true, onClick: () => removeEdge(id) },
|
||||
],
|
||||
});
|
||||
};
|
||||
|
||||
const onCanvasContextMenu = (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
setCtxMenu({
|
||||
x: e.clientX, y: e.clientY,
|
||||
items: [
|
||||
{ label: 'Add subnet here', icon: <GitMerge size={12} />,
|
||||
onClick: () => {
|
||||
const rect = canvasRef.current?.getBoundingClientRect();
|
||||
const wx = e.clientX - (rect?.left ?? 0) - pan.x;
|
||||
const wy = e.clientY - (rect?.top ?? 0) - pan.y;
|
||||
void onPaletteDrop(
|
||||
{ kind: 'network-subnet', slug: 'subnet', label: 'SUBNET', clientX: e.clientX, clientY: e.clientY },
|
||||
{ x: wx, y: wy }, null, null,
|
||||
);
|
||||
},
|
||||
},
|
||||
{ label: 'Add DMZ here', icon: <ShieldAlert size={12} />,
|
||||
onClick: () => {
|
||||
const rect = canvasRef.current?.getBoundingClientRect();
|
||||
const wx = e.clientX - (rect?.left ?? 0) - pan.x;
|
||||
const wy = e.clientY - (rect?.top ?? 0) - pan.y;
|
||||
void onPaletteDrop(
|
||||
{ kind: 'network-dmz', slug: 'dmz', label: 'DMZ', clientX: e.clientX, clientY: e.clientY },
|
||||
{ x: wx, y: wy }, null, null,
|
||||
);
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
};
|
||||
|
||||
return {
|
||||
ctxMenu,
|
||||
closeMenu,
|
||||
onNodeContextMenu,
|
||||
onNetContextMenu,
|
||||
onEdgeContextMenu,
|
||||
onCanvasContextMenu,
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user