Skip to content
Open
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
38 changes: 38 additions & 0 deletions frontend/src/components/CollaborativeCanvas.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import ReactFlow, {
NodeChange,
} from 'reactflow';
import 'reactflow/dist/style.css';
import { nodeTypes } from './whiteboard/Web3Nodes';

interface CollaborativeCanvasProps {
roomId: string;
Expand Down Expand Up @@ -164,6 +165,22 @@ export function CollaborativeCanvas({ roomId, userId, onCanvasReady }: Collabora
});
};

const handleAddWeb3Node = (type: 'wallet' | 'contract' | 'actor') => {
addNode({
id: `node-${Date.now()}`,
type: type,
position: {
x: 150 + Math.random() * 200,
y: 150 + Math.random() * 200,
},
data: {
label: type.charAt(0).toUpperCase() + type.slice(1),
...(type === 'wallet' ? { address: '0x1234...abcd' } : {}),
...(type === 'contract' ? { network: 'Ethereum' } : {}),
},
});
};

const onNodesChange = useCallback(
(changes: NodeChange[]) => {
changes.forEach((change) => {
Expand Down Expand Up @@ -244,6 +261,26 @@ export function CollaborativeCanvas({ roomId, userId, onCanvasReady }: Collabora
>
Add Circle
</button>
<div className="h-6 w-px bg-gray-300 dark:bg-gray-600 mx-1" />
<button
onClick={() => handleAddWeb3Node('wallet')}
className="rounded-md bg-purple-500 px-4 py-2 text-sm font-semibold text-white transition hover:bg-purple-400"
>
Add Wallet
</button>
<button
onClick={() => handleAddWeb3Node('contract')}
className="rounded-md bg-blue-500 px-4 py-2 text-sm font-semibold text-white transition hover:bg-blue-400"
>
Add Contract
</button>
<button
onClick={() => handleAddWeb3Node('actor')}
className="rounded-md bg-green-500 px-4 py-2 text-sm font-semibold text-white transition hover:bg-green-400"
>
Add Actor
</button>
<div className="h-6 w-px bg-gray-300 dark:bg-gray-600 mx-1" />
<button
onClick={handleExportImage}
disabled={isExporting}
Expand Down Expand Up @@ -286,6 +323,7 @@ export function CollaborativeCanvas({ roomId, userId, onCanvasReady }: Collabora
<ReactFlow
nodes={defaultNodes}
edges={defaultEdges}
nodeTypes={nodeTypes}
onNodesChange={onNodesChange}
onEdgesChange={onEdgesChange}
onConnect={onConnect}
Expand Down
47 changes: 47 additions & 0 deletions frontend/src/components/whiteboard/Web3Nodes.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { Handle, Position, NodeProps } from 'reactflow';
import React from 'react';

export function WalletNode({ data }: NodeProps) {
return (
<div className="rounded-xl border-2 border-purple-500 bg-purple-100 p-4 shadow-md w-48">
<Handle type="target" position={Position.Top} className="w-3 h-3" />
<div className="flex flex-col items-center">
<div className="text-2xl mb-2">👛</div>
<div className="font-bold text-purple-900">{data.label}</div>
<div className="text-xs text-purple-700 mt-1">{data.address || '0x...'}</div>
</div>
<Handle type="source" position={Position.Bottom} className="w-3 h-3" />
</div>
);
}

export function ContractNode({ data }: NodeProps) {
return (
<div className="rounded-xl border-2 border-blue-500 bg-blue-100 p-4 shadow-md w-48">
<Handle type="target" position={Position.Top} className="w-3 h-3" />
<div className="flex flex-col items-center">
<div className="text-2xl mb-2">📄</div>
<div className="font-bold text-blue-900">{data.label}</div>
<div className="text-xs text-blue-700 mt-1">{data.network || 'Ethereum'}</div>
</div>
<Handle type="source" position={Position.Bottom} className="w-3 h-3" />
</div>
);
}

export function ActorNode({ data }: NodeProps) {
return (
<div className="rounded-full border-2 border-green-500 bg-green-100 p-4 shadow-md w-32 h-32 flex flex-col justify-center items-center">
<Handle type="target" position={Position.Top} className="w-3 h-3" />
<div className="text-3xl mb-1">👤</div>
<div className="font-bold text-green-900 text-sm text-center">{data.label}</div>
<Handle type="source" position={Position.Bottom} className="w-3 h-3" />
</div>
);
}

export const nodeTypes = {
wallet: WalletNode,
contract: ContractNode,
actor: ActorNode,
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
import React from 'react';
import { render, screen, fireEvent } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { CollaborativeCanvas } from '../CollaborativeCanvas';
import * as canvasHooks from '@/hooks/useCanvasCollaboration';

// Mock html2canvas and jspdf
vi.mock('html2canvas', () => ({
default: vi.fn().mockResolvedValue({
toDataURL: vi.fn().mockReturnValue('data:image/png;base64,mock'),
width: 800,
height: 600,
}),
}));

vi.mock('jspdf', () => {
return {
default: vi.fn().mockImplementation(() => ({
internal: {
pageSize: {
getWidth: () => 210,
getHeight: () => 297,
},
},
addImage: vi.fn(),
save: vi.fn(),
})),
};
});

// Mock hooks
vi.mock('@/hooks/useCanvasCollaboration', () => {
return {
useCanvasCollaboration: vi.fn(),
useSharedCanvas: vi.fn(),
useAwareness: vi.fn(),
};
});

// Mock ResizeObserver for React Flow
class ResizeObserverMock {
observe() {}
unobserve() {}
disconnect() {}
}
global.ResizeObserver = ResizeObserverMock;

describe('CollaborativeCanvas', () => {
const mockAddNode = vi.fn();

beforeEach(() => {
vi.clearAllMocks();

vi.mocked(canvasHooks.useCanvasCollaboration).mockReturnValue({
doc: {} as any,
awareness: {},
isConnected: true,
});

vi.mocked(canvasHooks.useSharedCanvas).mockReturnValue({
nodes: [],
edges: [],
addNode: mockAddNode,
updateNode: vi.fn(),
deleteNode: vi.fn(),
addEdge: vi.fn(),
deleteEdge: vi.fn(),
});

vi.mocked(canvasHooks.useAwareness).mockReturnValue([
{ clientId: 2, name: 'Remote User', color: '#ff0000' }
]);
});

it('renders correctly', () => {
render(<CollaborativeCanvas roomId="test-room" userId="user-1" />);

expect(screen.getByText('Canvas: test-room')).toBeInTheDocument();
expect(screen.getByText(/1 collaborator/)).toBeInTheDocument();
});

it('adds standard shape nodes', () => {
render(<CollaborativeCanvas roomId="test-room" userId="user-1" />);

fireEvent.click(screen.getByText('Add Rectangle'));
expect(mockAddNode).toHaveBeenCalledWith(expect.objectContaining({
type: 'default',
data: { label: 'Rectangle' }
}));
});

it('adds web3 specific nodes', () => {
render(<CollaborativeCanvas roomId="test-room" userId="user-1" />);

fireEvent.click(screen.getByText('Add Wallet'));
expect(mockAddNode).toHaveBeenCalledWith(expect.objectContaining({
type: 'wallet',
data: expect.objectContaining({ label: 'Wallet', address: '0x1234...abcd' })
}));

fireEvent.click(screen.getByText('Add Contract'));
expect(mockAddNode).toHaveBeenCalledWith(expect.objectContaining({
type: 'contract',
data: expect.objectContaining({ label: 'Contract', network: 'Ethereum' })
}));

fireEvent.click(screen.getByText('Add Actor'));
expect(mockAddNode).toHaveBeenCalledWith(expect.objectContaining({
type: 'actor',
data: expect.objectContaining({ label: 'Actor' })
}));
});
});
114 changes: 114 additions & 0 deletions frontend/src/hooks/__tests__/useCanvasCollaboration.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
import { renderHook, act } from '@testing-library/react';
import { describe, it, expect, vi, beforeEach } from 'vitest';
import * as Y from 'yjs';
import { useCanvasCollaboration, useSharedCanvas, useAwareness } from '../useCanvasCollaboration';

vi.mock('y-websocket', () => {
return {
WebsocketProvider: vi.fn().mockImplementation(() => ({
awareness: {
setLocalState: vi.fn(),
on: vi.fn(),
off: vi.fn(),
getStates: vi.fn().mockReturnValue(new Map()),
clientID: 1,
},
on: vi.fn((event, cb) => {
if (event === 'status') {
setTimeout(() => cb({ status: 'connected' }), 10);
}
}),
disconnect: vi.fn(),
})),
};
});

describe('useCanvasCollaboration hooks', () => {
beforeEach(() => {
vi.clearAllMocks();
});

describe('useCanvasCollaboration', () => {
it('initializes connection and doc', async () => {
const { result } = renderHook(() => useCanvasCollaboration('room-1', 'user-1'));

expect(result.current.doc).toBeInstanceOf(Y.Doc);
expect(result.current.awareness).toBeDefined();

// Initially disconnected
expect(result.current.isConnected).toBe(false);

// Wait for mock status event
await new Promise((resolve) => setTimeout(resolve, 20));
expect(result.current.isConnected).toBe(true);
});
});

describe('useSharedCanvas', () => {
it('manages nodes and edges', () => {
const doc = new Y.Doc();
const nodesArray = doc.getArray('nodes');
const edgesArray = doc.getArray('edges');

const { result } = renderHook(() => useSharedCanvas(doc));

expect(result.current.nodes).toEqual([]);
expect(result.current.edges).toEqual([]);

act(() => {
result.current.addNode({
id: 'n1',
type: 'wallet',
position: { x: 0, y: 0 },
data: { label: 'Wallet' }
});
});

expect(result.current.nodes).toHaveLength(1);
expect(result.current.nodes[0].id).toBe('n1');
expect(nodesArray.length).toBe(1);

act(() => {
result.current.updateNode('n1', { position: { x: 10, y: 10 } });
});

expect(result.current.nodes[0].position).toEqual({ x: 10, y: 10 });

act(() => {
result.current.addEdge({ id: 'e1', source: 'n1', target: 'n2' });
});

expect(result.current.edges).toHaveLength(1);
expect(result.current.edges[0].id).toBe('e1');

act(() => {
result.current.deleteNode('n1');
result.current.deleteEdge('e1');
});

expect(result.current.nodes).toHaveLength(0);
expect(result.current.edges).toHaveLength(0);
});
});

describe('useAwareness', () => {
it('tracks remote users', () => {
const mockAwareness = {
getStates: vi.fn().mockReturnValue(new Map([
[1, { user: { id: 'u1', name: 'User 1' } }],
[2, { user: { id: 'u2', name: 'User 2' } }]
])),
on: vi.fn(),
off: vi.fn(),
clientID: 1, // local client is 1
};

const { result } = renderHook(() => useAwareness(mockAwareness));

// Should only include client 2 (remote user)
expect(result.current).toHaveLength(1);
expect(result.current[0].clientId).toBe(2);
expect(result.current[0].id).toBe('u2');
});
});
});