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:
2026-05-09 05:34:58 -04:00
parent 5f2a3f4629
commit f33a011900
2 changed files with 347 additions and 0 deletions

View 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');
});
});

View 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,
};
}