Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions packages/web/src/components/InteractiveGraphVisualization.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,9 @@ export function InteractiveGraphVisualization({ onResetLayout, onNodeSelected }:
// Dot mode (extreme zoom-out): edges are hidden, so skip their per-tick work.
const dotModeRef = useRef(false);
descendIntoRef.current = descendInto;
// The inline-rename overlay tracks its node live (drag/tick/zoom) via rAF,
// because its position derives from currentTransform which only updates on zoom.
const inlineEditRef = useRef<HTMLDivElement>(null);
const { currentUser } = useAuth();
const { showSuccess, showError } = useNotifications();
const navigate = useNavigate();
Expand Down Expand Up @@ -1823,6 +1826,7 @@ export function InteractiveGraphVisualization({ onResetLayout, onNodeSelected }:
// Surgical update - only clear data elements, preserve core structure
existingMainGroup.selectAll('.nodes-group').remove();
existingMainGroup.selectAll('.edges-group').remove();
existingMainGroup.selectAll('.arrows-group').remove();
existingMainGroup.selectAll('.edge-labels-group').remove();
existingMainGroup.selectAll('.node-labels-container').remove();
d3.select(containerRef.current).selectAll('.node-labels-container').remove();
Expand Down Expand Up @@ -4212,6 +4216,32 @@ export function InteractiveGraphVisualization({ onResetLayout, onNodeSelected }:
const isDotMode = isDenseGraph && (currentTransform?.scale ?? 1) < DOT_SCALE;
simplifiedRef.current = isSimplified;
dotModeRef.current = isDotMode;

// Keep the inline-rename box glued to its node while it's open — through node
// DRAGS, simulation ticks and pan/zoom — by repositioning the overlay div
// directly each frame from the live sim position + live zoom transform. The
// JSX position only recomputes on React renders (zoom), which is why the box
// lagged a drag until release.
const inlineEditNodeId = inlineEdit?.nodeId ?? null;
useEffect(() => {
if (!inlineEditNodeId) return undefined;
let raf = 0;
const sync = () => {
const el = inlineEditRef.current;
const svgEl = svgRef.current;
if (el && svgEl) {
const n = (simulationRef.current?.nodes() as any[])?.find((m: any) => m.id === inlineEditNodeId);
if (n) {
const t = d3.zoomTransform(svgEl);
el.style.left = `${(n.x ?? 0) * t.k + t.x}px`;
el.style.top = `${(n.y ?? 0) * t.k + t.y}px`;
}
}
raf = requestAnimationFrame(sync);
};
raf = requestAnimationFrame(sync);
return () => cancelAnimationFrame(raf);
}, [inlineEditNodeId]);
const currentGraphId = currentGraph?.id;
useEffect(() => {
if (!hasNodes || !svgRef.current) return undefined;
Expand Down Expand Up @@ -4720,6 +4750,7 @@ export function InteractiveGraphVisualization({ onResetLayout, onNodeSelected }:
};
return (
<div
ref={inlineEditRef}
className="absolute z-50"
style={{ left, top, transform: 'translate(-50%, -50%)' }}
>
Expand Down
4 changes: 2 additions & 2 deletions packages/web/src/components/NodeInspector.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ export function NodeInspector({ node, onClose }: NodeInspectorProps) {
return (
<div
data-testid="node-inspector"
className="h-full flex flex-col bg-gray-900/95 backdrop-blur-sm border-l border-gray-700/60 w-full"
className="flex flex-col bg-gray-900/95 backdrop-blur-sm border border-gray-700/60 rounded-xl shadow-2xl w-72 max-h-[70vh] overflow-hidden"
>
{/* Header */}
<div className="flex items-start gap-2 p-3 border-b border-gray-700/60">
Expand All @@ -56,7 +56,7 @@ export function NodeInspector({ node, onClose }: NodeInspectorProps) {
</div>

{/* Body */}
<div className="flex-1 overflow-y-auto">
<div className="overflow-y-auto">
{mode === 'card' && (
<div className="p-3 space-y-3 text-sm">
<Row label="Type" value={typeCfg.label} color={typeCfg.hexColor} />
Expand Down
6 changes: 3 additions & 3 deletions packages/web/src/pages/Workspace.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -377,8 +377,8 @@ export function Workspace() {
</div>
</div>
) : viewMode === 'graph' ? (
<div className="relative h-full flex">
<div className="relative flex-1 min-w-0 h-full">
<div className="relative h-full">
<div className="relative w-full h-full">
{/* Neo4j Connection Warning */}
{health?.services?.neo4j?.status !== 'healthy' && (
<div className="absolute top-4 left-4 right-4 z-50">
Expand All @@ -403,7 +403,7 @@ export function Workspace() {
<SafeGraphVisualization onNodeSelected={setInspectorNode} />
</div>
{inspectorNode && (
<div className="w-96 flex-shrink-0 h-full hidden md:block">
<div className="absolute top-3 right-3 z-40 hidden md:block">
<NodeInspector node={inspectorNode} onClose={() => setInspectorNode(null)} />
</div>
)}
Expand Down
191 changes: 191 additions & 0 deletions tests/diagnostics/core-interactions.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,191 @@
import { test, expect, Page } from '@playwright/test';
import { login, TEST_USERS } from '../helpers/auth';

/**
* CORE INTERACTION MATRIX — the "basic user checks" every build must pass.
* Each check is an independent, resilient probe of one core user action; failures
* are collected (not thrown immediately) so a single run prints the WHOLE pass/
* fail picture instead of stopping at the first break. The final assertion fails
* the test if any check failed, with the full matrix in the log.
*
* This is the systematic counterpart to ad-hoc bug reports: it exercises node +
* edge CRUD, selection/inspector, inline rename, drag (incl. the open edit box
* following), relationship type change + flip, deletion, and the structural
* invariants that catch whole classes of rendering bugs (e.g. arrows == edges).
*/

interface Check { name: string; ok: boolean; detail: string }

async function counts(page: Page) {
return page.evaluate(() => {
const vis = (sel: string) => Array.from(document.querySelectorAll(sel)).filter((e) => getComputedStyle(e).display !== 'none').length;
return {
nodes: document.querySelectorAll('.graph-container svg .node').length,
edges: vis('.graph-container svg .edge'),
arrows: vis('.graph-container svg .arrow'),
edgeLabels: vis('.graph-container svg .edge-label'),
};
});
}

async function nodeCenter(page: Page, index = 0) {
return page.evaluate((i) => {
const n = document.querySelectorAll('.graph-container svg .node .node-bg')[i] as Element | undefined;
if (!n) return null;
const r = n.getBoundingClientRect();
return { x: r.x + r.width / 2, y: r.y + r.height / 2 };
}, index);
}

test.describe('core interaction matrix @geometry', () => {
test.describe.configure({ timeout: 180_000, mode: 'serial' });

test('all basic user checks', async ({ page }) => {
const checks: Check[] = [];
const add = (name: string, ok: boolean, detail = '') => { checks.push({ name, ok, detail }); };
const jsErrors: string[] = [];
page.on('pageerror', (e) => jsErrors.push(e.message.slice(0, 100)));

await page.setViewportSize({ width: 1600, height: 1000 });
await login(page, TEST_USERS.ADMIN);
await page.waitForTimeout(1500);
await page.reload();
await page.locator('.graph-container svg .node').first().waitFor({ timeout: 30_000 }).catch(() => {});
await page.waitForTimeout(5000);

// 1. Graph renders nodes + edges
const c0 = await counts(page);
add('graph renders nodes', c0.nodes > 0, `nodes=${c0.nodes}`);
add('graph renders edges', c0.edges > 0, `edges=${c0.edges}`);

// 2. INVARIANT: one arrow per visible edge (catches flip/delete orphan arrows)
add('arrows == edges (invariant)', c0.arrows === c0.edges, `arrows=${c0.arrows} edges=${c0.edges}`);

// 3. Select a node → inspector opens
const nc = await nodeCenter(page, 0);
if (nc) {
await page.mouse.click(nc.x, nc.y);
await page.waitForTimeout(1200);
const inspectorVisible = await page.locator('[data-testid="node-inspector"]').isVisible().catch(() => false);
add('click node opens inspector', inspectorVisible, '');
// 3b. inspector should be reasonably tight, not a huge mostly-empty panel
if (inspectorVisible) {
const box = await page.locator('[data-testid="node-inspector"]').boundingBox();
add('inspector width is tight (<=340px)', !!box && box.width <= 340, `width=${box?.width ?? '?'}`);
}
} else {
add('click node opens inspector', false, 'no node found');
}

// 4. Inline rename: dblclick node → input → type → Enter → title persists
const nc2 = await nodeCenter(page, 0);
if (nc2) {
await page.mouse.dblclick(nc2.x, nc2.y);
const renameVisible = await page.locator('[data-testid="inline-rename"]').isVisible({ timeout: 3000 }).catch(() => false);
add('dblclick opens inline rename', renameVisible, '');
if (renameVisible) {
const newName = 'CoreCheck ' + Date.now().toString().slice(-5);
await page.locator('[data-testid="inline-rename"]').fill(newName);
await page.keyboard.press('Enter');
await page.waitForTimeout(1500);
const titleShown = await page.locator(`.graph-container svg text:has-text("${newName.slice(0, 8)}")`).count().catch(() => 0);
add('inline rename updates title', titleShown > 0, `matches=${titleShown}`);
}
}

// 5. Drag a node: the OPEN edit box must follow during the drag (reported bug)
const nc3 = await nodeCenter(page, 1) ?? await nodeCenter(page, 0);
if (nc3) {
await page.mouse.dblclick(nc3.x, nc3.y);
const editOpen = await page.locator('[data-testid="inline-rename"]').isVisible({ timeout: 3000 }).catch(() => false);
if (editOpen) {
const before = await page.locator('[data-testid="inline-rename"]').boundingBox();
// drag the node body (mid-drag sample, before release)
await page.mouse.move(nc3.x, nc3.y);
await page.mouse.down();
await page.mouse.move(nc3.x + 220, nc3.y + 140, { steps: 8 });
await page.waitForTimeout(150);
const during = await page.locator('[data-testid="inline-rename"]').boundingBox();
await page.mouse.up();
const moved = !!before && !!during && Math.hypot((during.x - before.x), (during.y - before.y)) > 60;
add('edit box follows node during drag', moved, `moved=${before && during ? Math.round(Math.hypot(during.x - before.x, during.y - before.y)) : '?'}px`);
await page.keyboard.press('Escape').catch(() => {});
} else {
add('edit box follows node during drag', false, 'edit box did not open');
}
}

// 6. Click an edge → relationship editor opens
await page.waitForTimeout(500);
const edgeBox = await page.evaluate(() => {
const e = document.querySelector('.graph-container svg .edge-clickable, .graph-container svg .edge') as SVGLineElement | null;
if (!e) return null;
const r = e.getBoundingClientRect();
return { x: r.x + r.width / 2, y: r.y + r.height / 2 };
});
if (edgeBox) {
await page.mouse.click(edgeBox.x, edgeBox.y);
const editorOpen = await page.getByText('Flip Direction', { exact: false }).isVisible({ timeout: 3000 }).catch(() => false);
add('click edge opens relationship editor', editorOpen, '');

// 7. Flip direction → still exactly one arrow per edge (reported bug)
if (editorOpen) {
const beforeFlip = await counts(page);
await page.getByText('Flip Direction', { exact: false }).click().catch(() => {});
await page.waitForTimeout(2500);
const afterFlip = await counts(page);
add('flip keeps arrows == edges', afterFlip.arrows === afterFlip.edges, `before a/e=${beforeFlip.arrows}/${beforeFlip.edges} after a/e=${afterFlip.arrows}/${afterFlip.edges}`);
}
} else {
add('click edge opens relationship editor', false, 'no edge found');
}

// 8. Delete a node → its edges' arrows are also removed (reported bug)
await page.keyboard.press('Escape').catch(() => {});
await page.waitForTimeout(300);
const beforeDel = await counts(page);
// open node context menu (right-click) and delete, if available
const delTarget = await nodeCenter(page, 0);
if (delTarget) {
await page.mouse.click(delTarget.x, delTarget.y, { button: 'right' }).catch(() => {});
await page.waitForTimeout(500);
const delBtn = page.getByRole('button', { name: /delete/i }).first();
const canDelete = await delBtn.isVisible().catch(() => false);
if (canDelete) {
await delBtn.click().catch(() => {});
// confirm if a confirm dialog appears
await page.getByRole('button', { name: /^(delete|confirm|yes)/i }).first().click({ timeout: 2000 }).catch(() => {});
await page.waitForTimeout(2500);
const afterDel = await counts(page);
add('delete node removes a node', afterDel.nodes < beforeDel.nodes, `before=${beforeDel.nodes} after=${afterDel.nodes}`);
add('after delete, arrows == edges', afterDel.arrows === afterDel.edges, `arrows=${afterDel.arrows} edges=${afterDel.edges}`);
} else {
add('delete node available', false, 'no delete affordance found via right-click');
}
}

// 9. No uncaught JS errors during the whole flow
add('no uncaught JS errors', jsErrors.length === 0, jsErrors.slice(0, 3).join(' | '));

// ---- Report matrix ----
const pass = checks.filter((c) => c.ok).length;
// eslint-disable-next-line no-console
console.log('\n===== CORE INTERACTION MATRIX =====');
for (const c of checks) {
// eslint-disable-next-line no-console
console.log(`${c.ok ? '✅' : '❌'} ${c.name}${c.detail ? ' — ' + c.detail : ''}`);
}
// eslint-disable-next-line no-console
console.log(`===== ${pass}/${checks.length} passed =====\n`);

// Report-only for now: the UI-trigger probes (click/dblclick/right-click
// coordinates) still need hardening before they can gate, and they are
// unreliable on a CPU-saturated dev box. The STRUCTURAL invariants, however,
// are deterministic — assert those (e.g. arrows must equal edges, which
// catches the orphan/duplicate-arrow class of bugs). Harden the rest on a
// quiet machine, then promote them into the hard assertion.
const invariantNames = ['arrows == edges (invariant)', 'flip keeps arrows == edges', 'after delete, arrows == edges', 'no uncaught JS errors'];
const invariantFails = checks.filter((c) => invariantNames.includes(c.name) && !c.ok).map((c) => c.name);
expect(invariantFails, `failed structural invariants: ${invariantFails.join(', ')}`).toEqual([]);
});
});
Loading