Skip to content

Commit 55488c1

Browse files
authored
feat: Add interactive GitHub Repository Dependency Graph (JhaSourav07#1623)
Closes JhaSourav07#1183 ## Description This PR introduces an interactive **GitHub Repository Dependency Graph** to visualize a user's repository network, forks, and open-source contributions. ### Key Changes ## Pillar - [ ] 🎨 Pillar 1 — New Theme Design - [x] 📐 Pillar 2 — Geometric SVG Improvement - [ ] 🕐 Pillar 3 — Timezone Logic Optimization - [x] 🛠️ Other (New UI visualization widget & GraphQL integration) ## Visual Preview - Interactive 2D force-directed relationship graph rendered at the bottom of the user's dashboard view. https://github.com/user-attachments/assets/655018b3-4e00-49cc-ab3e-c26f3a9f2b68 <img width="1366" height="798" alt="Screenshot 2026-05-30 100932" src="https://github.com/user-attachments/assets/8f84a132-002d-4052-920d-3229f3b98a51" /> ## Checklist before requesting a review: - [x] I have read the `CONTRIBUTING.md` file. - [x] I have tested these changes locally. - [x] I have run `npm run format` and `npm run lint` locally and resolved all errors (CI will fail otherwise). - [x] My commits follow the Conventional Commits format. - [] I have updated `README.md` if I added a new theme or URL parameter. - [x] I have starred the repo. - [x] I have made sure that I have only one commit to merge in this PR. - [x] The SVG output matches the CommitPulse "premium quality" aesthetic standard (no raw elements, smooth animations, correct fonts). - [x] (Recommended) I joined the CommitPulse Discord community for contributor discussions, mentorship, and faster PR support.
2 parents b7cc5c4 + 9d50f63 commit 55488c1

10 files changed

Lines changed: 1038 additions & 12 deletions

File tree

app/(root)/dashboard/[username]/page.test.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,6 +91,7 @@ describe('DashboardPage', () => {
9191
insights: [],
9292
achievements: [],
9393
commitClock: [],
94+
graphData: { nodes: [], links: [] },
9495
};
9596

9697
beforeEach(() => {

components/dashboard/DashboardClient.test.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,7 @@ const mockInitialData = {
134134
insights: [{ id: '1', icon: 'zap', text: 'Highly active in mornings' }],
135135
achievements: [],
136136
commitClock: [],
137+
graphData: { nodes: [], links: [] },
137138
};
138139

139140
const mockSecondData = {
@@ -170,6 +171,7 @@ const mockSecondData = {
170171
insights: [{ id: '1', icon: 'zap', text: 'Hard worker' }],
171172
achievements: [],
172173
commitClock: [],
174+
graphData: { nodes: [], links: [] },
173175
};
174176

175177
const initialDataWithHigherStreak = {

components/dashboard/DashboardClient.tsx

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { X, RefreshCw } from 'lucide-react';
66
import Link from 'next/link';
77
import { toast } from 'sonner';
88
import type { Achievement } from '@/types/dashboard';
9+
import type { GraphNode, GraphLink } from '@/types';
910

1011
import RefreshButton from './RefreshButton';
1112
import ProfileCard from './ProfileCard';
@@ -16,6 +17,7 @@ import CommitClock from './CommitClock';
1617
import Heatmap from './Heatmap';
1718
import AIInsights from './AIInsights';
1819
import StatsCard from './StatsCard';
20+
import RepositoryGraph from './RepositoryGraph';
1921
import ComparisonStatsCard from './ComparisonStatsCard';
2022
import RadarChart from './RadarChart';
2123
import GrowthTrendChart from './GrowthTrendChart';
@@ -63,6 +65,10 @@ interface DashboardData {
6365
day: string;
6466
commits: number;
6567
}>;
68+
graphData: {
69+
nodes: GraphNode[];
70+
links: GraphLink[];
71+
};
6672
}
6773

6874
interface DashboardClientProps {
@@ -505,7 +511,7 @@ export default function DashboardClient({ initialData, username }: DashboardClie
505511
/* Standard Single Profile View */
506512
<div className="grid grid-cols-1 lg:grid-cols-[300px_1fr_320px] gap-6 lg:gap-8">
507513
{/* Left Sidebar */}
508-
<aside className="flex flex-col gap-6">
514+
<aside className="flex flex-col gap-6 lg:row-span-2">
509515
<ProfileCard
510516
user={initialData.profile}
511517
exportData={{
@@ -562,6 +568,11 @@ export default function DashboardClient({ initialData, username }: DashboardClie
562568

563569
<AIInsights insights={initialData.insights} />
564570
</aside>
571+
572+
{/* Repository Graph Section */}
573+
<div className="col-span-1 lg:col-span-2 lg:col-start-2">
574+
<RepositoryGraph data={initialData.graphData} />
575+
</div>
565576
</div>
566577
) : (
567578
/* Compare Mode Split-Dashboard View */
Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
1+
/* eslint-disable @typescript-eslint/no-explicit-any */
2+
import { describe, it, expect, vi, beforeEach } from 'vitest';
3+
import { render, screen, fireEvent, act } from '@testing-library/react';
4+
import React from 'react';
5+
import RepositoryGraph from './RepositoryGraph';
6+
import type { GraphNode, GraphLink } from '@/types';
7+
8+
// Mock next/dynamic to return our ForceGraphMock component directly and synchronously
9+
vi.mock('next/dynamic', () => {
10+
const DynamicForceGraphMock = React.forwardRef((props: any, ref: any) => {
11+
React.useImperativeHandle(ref, () => ({
12+
centerAt: vi.fn(),
13+
zoom: vi.fn(),
14+
}));
15+
16+
return (
17+
<div data-testid="force-graph-2d">
18+
{props.graphData?.nodes?.map((node: any) => (
19+
<button
20+
key={node.id}
21+
data-testid={`graph-node-${node.id}`}
22+
onClick={() => props.onNodeClick?.(node)}
23+
onMouseEnter={() => props.onNodeHover?.(node)}
24+
onMouseLeave={() => props.onNodeHover?.(null)}
25+
>
26+
{node.name}
27+
</button>
28+
))}
29+
</div>
30+
);
31+
});
32+
DynamicForceGraphMock.displayName = 'ForceGraph2D';
33+
return {
34+
default: () => DynamicForceGraphMock,
35+
};
36+
});
37+
38+
// Mock window.innerWidth/innerHeight or clientWidth/clientHeight
39+
Object.defineProperty(HTMLElement.prototype, 'clientWidth', {
40+
configurable: true,
41+
value: 800,
42+
});
43+
44+
describe('RepositoryGraph', () => {
45+
const mockData = {
46+
nodes: [
47+
{ id: 'user1', name: 'User 1', type: 'User', val: 30, color: '#FFF' },
48+
{
49+
id: 'repo1',
50+
name: 'Repo 1',
51+
type: 'Repo',
52+
val: 15,
53+
color: '#FFF',
54+
stats: { stars: 10, forks: 2, language: 'TypeScript', updatedAt: '2024-05-30T00:00:00Z' },
55+
},
56+
{
57+
id: 'fork1',
58+
name: 'Fork 1',
59+
type: 'Fork',
60+
val: 10,
61+
color: '#FFF',
62+
stats: { stars: 0, forks: 0, language: 'JavaScript' },
63+
},
64+
{
65+
id: 'contrib1',
66+
name: 'Contrib 1',
67+
type: 'Contribution',
68+
val: 20,
69+
color: '#FFF',
70+
stats: { stars: 100, forks: 50, language: 'Rust' },
71+
},
72+
] as GraphNode[],
73+
links: [
74+
{ source: 'user1', target: 'repo1' },
75+
{ source: 'user1', target: 'fork1' },
76+
{ source: 'user1', target: 'contrib1' },
77+
] as GraphLink[],
78+
};
79+
80+
beforeEach(() => {
81+
vi.clearAllMocks();
82+
});
83+
84+
it('renders the empty state if data has 1 or fewer nodes', () => {
85+
const emptyData = { nodes: [mockData.nodes[0]], links: [] };
86+
render(<RepositoryGraph data={emptyData} />);
87+
expect(screen.getByText('No repository relationship data available yet.')).toBeDefined();
88+
});
89+
90+
it('renders the title and filter buttons', () => {
91+
render(<RepositoryGraph data={mockData} />);
92+
expect(screen.getByText('🌐 Repository Dependency Graph')).toBeDefined();
93+
expect(screen.getByText('Personal')).toBeDefined();
94+
expect(screen.getByText('Contributions')).toBeDefined();
95+
expect(screen.getByText('Forks')).toBeDefined();
96+
});
97+
98+
it('toggles filters when clicked', () => {
99+
render(<RepositoryGraph data={mockData} />);
100+
101+
const personalBtn = screen.getByText('Personal');
102+
const contributionsBtn = screen.getByText('Contributions');
103+
const forksBtn = screen.getByText('Forks');
104+
105+
// Turn off Personal
106+
fireEvent.click(personalBtn);
107+
expect(screen.queryByTestId('graph-node-repo1')).toBeNull();
108+
109+
// Turn off Contributions
110+
fireEvent.click(contributionsBtn);
111+
expect(screen.queryByTestId('graph-node-contrib1')).toBeNull();
112+
113+
// Turn off Forks
114+
fireEvent.click(forksBtn);
115+
expect(screen.queryByTestId('graph-node-fork1')).toBeNull();
116+
117+
// Turn Personal back on
118+
fireEvent.click(personalBtn);
119+
expect(screen.getByTestId('graph-node-repo1')).toBeDefined();
120+
});
121+
122+
it('displays a tooltip on node hover', async () => {
123+
render(<RepositoryGraph data={mockData} />);
124+
125+
const repoNode = screen.getByTestId('graph-node-repo1');
126+
127+
// Hover
128+
fireEvent.mouseEnter(repoNode);
129+
expect(screen.getAllByText('Repo 1').length).toBeGreaterThan(1);
130+
expect(screen.getByText('10 Stars')).toBeDefined();
131+
expect(screen.getByText('2 Forks')).toBeDefined();
132+
expect(screen.getByText('TypeScript')).toBeDefined();
133+
134+
// Unhover
135+
fireEvent.mouseLeave(repoNode);
136+
expect(screen.queryByText('10 Stars')).toBeNull();
137+
});
138+
139+
it('zooms on node click', () => {
140+
render(<RepositoryGraph data={mockData} />);
141+
const repoNode = screen.getByTestId('graph-node-repo1');
142+
fireEvent.click(repoNode);
143+
// Click successfully processed without throwing
144+
expect(repoNode).toBeDefined();
145+
});
146+
147+
it('handles window resize', () => {
148+
render(<RepositoryGraph data={mockData} />);
149+
act(() => {
150+
window.dispatchEvent(new Event('resize'));
151+
});
152+
// Check that it didn't throw
153+
expect(screen.getByText('🌐 Repository Dependency Graph')).toBeDefined();
154+
});
155+
156+
it('calculates and renders correct insights in the sidebar', () => {
157+
render(<RepositoryGraph data={mockData} />);
158+
// mockData has 1 repo + 1 fork = 2 ecosystem size
159+
expect(screen.getByText('2 Repositories connected')).toBeDefined();
160+
// Contrib 1 is the most starred (100 stars)
161+
expect(screen.getAllByText(/Contrib 1/).length).toBeGreaterThan(0);
162+
});
163+
});

0 commit comments

Comments
 (0)