Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implement User Details Page #349

Merged
merged 8 commits into from
Jan 8, 2025
Merged
Show file tree
Hide file tree
Changes from 3 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
26 changes: 24 additions & 2 deletions backend/apps/github/api/user.py
arkid15r marked this conversation as resolved.
Show resolved Hide resolved
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
"""User API."""

from rest_framework import serializers, viewsets
from rest_framework.decorators import action
from rest_framework.response import Response

from apps.github.models.user import User

Expand All @@ -12,10 +14,19 @@ class UserSerializer(serializers.HyperlinkedModelSerializer):
class Meta:
model = User
fields = (
"name",
"login",
"avatar_url",
"bio",
"company",
"email",
"followers_count",
"following_count",
"location",
"login",
"name",
"public_repositories_count",
"title",
"twitter_username",
"url",
"created_at",
"updated_at",
)
Expand All @@ -27,3 +38,14 @@ class UserViewSet(viewsets.ReadOnlyModelViewSet):

queryset = User.objects.all()
serializer_class = UserSerializer

@action(detail=False, methods=["get"], url_path="login/(?P<login>[^/.]+)")
def get_user_by_login(self, request, login=None):
"""Get user by login."""
try:
user = User.objects.get(login=login)
serializer = self.get_serializer(user)
data = serializer.data
return Response(data)
except User.DoesNotExist:
return Response({"detail": "User not found."}, status=404)
7 changes: 4 additions & 3 deletions frontend/__tests__/src/App.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,10 @@ jest.mock('pages', () => ({
CommitteesPage: () => <div data-testid="committees-page">Committees Page</div>,
ChaptersPage: () => <div data-testid="chapters-page">Chapters Page</div>,
ContributePage: () => <div data-testid="contribute-page">Contribute Page</div>,
ChapterDetailsPage: () => <div data-testid="contribute-page">ChapterDetailsPage Page</div>,
CommitteeDetailsPage: () => <div data-testid="contribute-page">CommitteeDetails Page</div>,
ProjectDetailsPage: () => <div data-testid="contribute-page">ProjectDetails Page</div>,
ChapterDetailsPage: () => <div data-testid="chapterdetails-page">ChapterDetailsPage Page</div>,
CommitteeDetailsPage: () => <div data-testid="committeedetails-page">CommitteeDetails Page</div>,
ProjectDetailsPage: () => <div data-testid="projectdetails-page">ProjectDetails Page</div>,
UserDetailsPage: () => <div data-testid="userdetails-page">UserDetails Page</div>,
}))

jest.mock('components/Header', () => {
Expand Down
88 changes: 88 additions & 0 deletions frontend/__tests__/src/pages/UserDetails.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
import { act, render, screen, waitFor } from '@testing-library/react'
import { MemoryRouter, Route, Routes } from 'react-router-dom'
import UserDetailsPage from 'pages/UserDetails'
import '@testing-library/jest-dom'

jest.mock('utils/logger', () => ({
error: jest.fn(),
}))

const mockUser = {
login: 'testuser',
name: 'Test User',
avatar_url: 'https://example.com/avatar.jpg',
url: 'https://github.com/testuser',
bio: 'This is a test user',
company: 'Test Company',
location: 'Test Location',
twitter_username: 'testuser',
email: '[email protected]',
followers_count: 10,
following_count: 5,
public_repositories_count: 3,
created_at: '2020-01-01T00:00:00Z',
}

describe('UserDetailsPage', () => {
beforeEach(() => {
global.fetch = jest.fn(() =>
Promise.resolve({
json: () => Promise.resolve(mockUser),
})
) as jest.Mock
})

afterEach(() => {
jest.clearAllMocks()
})

test('renders loading spinner initially', async () => {
render(<UserDetailsPage />)
const loadingSpinner = screen.getAllByAltText('Loading indicator')
await waitFor(() => {
expect(loadingSpinner.length).toBeGreaterThan(0)
})
})

test('renders user details after fetching data', async () => {
await act(async () => {
render(
<MemoryRouter initialEntries={['/user/testuser']}>
<Routes>
<Route path="/user/:login" element={<UserDetailsPage />} />
</Routes>
</MemoryRouter>
)
})

await waitFor(() => expect(screen.getByText('Test User')).toBeInTheDocument())
expect(screen.getByText('@testuser')).toBeInTheDocument()
expect(screen.getByText('This is a test user')).toBeInTheDocument()
expect(screen.getByText('Test Company')).toBeInTheDocument()
expect(screen.getByText('Test Location')).toBeInTheDocument()
expect(screen.getByText('testuser')).toBeInTheDocument()
expect(screen.getByText('[email protected]')).toBeInTheDocument()
expect(screen.getByText('Followers')).toBeInTheDocument()
expect(screen.getByText('Following')).toBeInTheDocument()
expect(screen.getByText('Repositories')).toBeInTheDocument()
expect(screen.getByText('Joined January 1, 2020')).toBeInTheDocument()
})

test('renders error message when user does not exist', async () => {
global.fetch = jest.fn(() =>
Promise.resolve({
json: () => Promise.resolve(null),
})
) as jest.Mock

render(
<MemoryRouter initialEntries={['/user/nonexistentuser']}>
<Routes>
<Route path="/user/:login" element={<UserDetailsPage />} />
</Routes>
</MemoryRouter>
)

await waitFor(() => expect(screen.getByText('User does not exist')).toBeInTheDocument())
})
})
2 changes: 2 additions & 0 deletions frontend/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
ProjectDetailsPage,
CommitteeDetailsPage,
ChapterDetailsPage,
UserDetailsPage,
} from 'pages'
import { useEffect } from 'react'
import { Routes, Route, useLocation } from 'react-router-dom'
Expand All @@ -33,6 +34,7 @@ function App() {
<Route path="/committees/:committeeKey" element={<CommitteeDetailsPage />}></Route>
<Route path="/chapters" element={<ChaptersPage />}></Route>
<Route path="/chapters/:chapterKey" element={<ChapterDetailsPage />}></Route>
<Route path="/user/:login" element={<UserDetailsPage />}></Route>
arkid15r marked this conversation as resolved.
Show resolved Hide resolved
</Routes>
<Footer />
</main>
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/components/ContributorAvatar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ const ContributorAvatar = ({ contributor }: { contributor: topContributorsType }
<a
data-tooltip-id={`avatar-tooltip-${contributor.login}`}
data-tooltip-content={`${contributor.contributions_count} contributions by ${contributor.name}`}
href={`https://github.com/${contributor.login}`}
href={`/user/${contributor.login}`}
target="_blank"
>
<img
Expand Down
18 changes: 18 additions & 0 deletions frontend/src/lib/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,3 +114,21 @@ export interface AgloliaRequestType {
removeWordsIfNoResults: 'none' | 'lastWords' | 'firstWords' | 'allOptional'
typoTolerance?: string
}

export interface UserDetailsProps {
avatar_url: string
bio: string
company: string
email: string
followers_count: number
following_count: number
location: string
login: string
name: string
public_repositories_count: number
title: string
twitter_username: string
url: string
created_at: string
updated_at: string
}
176 changes: 176 additions & 0 deletions frontend/src/pages/UserDetails.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,176 @@
import { faGithub, faXTwitter } from '@fortawesome/free-brands-svg-icons'
import { faEnvelope } from '@fortawesome/free-regular-svg-icons'
import {
faBuildingUser,
faCode,
faCodeBranch,
faLocationDot,
faUserPlus,
faUser,
} from '@fortawesome/free-solid-svg-icons'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import React, { useState, useEffect } from 'react'
import { useParams } from 'react-router-dom'
import { API_URL } from 'utils/credentials'
import logger from 'utils/logger'
import { UserDetailsProps } from 'lib/types'
import LoadingSpinner from 'components/LoadingSpinner'

const UserDetailsPage: React.FC = () => {
const { login } = useParams()
const [user, setUser] = useState<UserDetailsProps | null>(null)
const [isLoading, setIsLoading] = useState(true)

useEffect(() => {
const fetchUserData = async () => {
try {
setIsLoading(true)
const response = await fetch(`${API_URL}/github/users/login/${login}`)
arkid15r marked this conversation as resolved.
Show resolved Hide resolved
const data = await response.json()
setUser(data)
} catch (error) {
logger.error(error)
} finally {
setIsLoading(false)
}
}

fetchUserData()
}, [login])

if (isLoading)
return (
<div className="flex min-h-[60vh] items-center justify-center">
<LoadingSpinner imageUrl="/img/owasp_icon_white_sm.png" />
</div>
)

if (!user) {
return <div className="flex h-screen items-center justify-center">User does not exist</div>
}

return (
<div className="mt-24 min-h-screen w-full p-4">
<div className="mx-auto md:max-w-3xl">
<div className="overflow-hidden rounded-3xl bg-white shadow-xl dark:bg-gray-800">
<div className="relative">
<div className="h-32 bg-owasp-blue"></div>
<div className="relative px-6">
<div className="flex flex-col items-start justify-between sm:flex-row sm:space-x-6">
<div className="flex flex-col items-center space-y-4 sm:flex-row sm:items-center sm:space-x-6 sm:space-y-0">
<div className="-mt-24 flex-shrink-0">
<img
className="h-40 w-40 rounded-full border-4 border-white object-cover shadow-lg dark:border-gray-800"
src={user.avatar_url}
alt={user.name}
/>
</div>
<div className="mt-6 sm:mt-0 sm:pb-4">
<h1 className="text-3xl font-bold text-gray-900 dark:text-white">
{user.name}
</h1>
<a
href={`https://www.github.com/${login}`}
target="_blank"
rel="noopener noreferrer"
className="text-lg text-gray-700 decoration-dotted hover:underline hover:underline-offset-2 dark:text-gray-300"
>
@{user.login}
</a>
</div>
</div>
<a
href={user.url}
target="_blank"
rel="noopener noreferrer"
className="group mt-4 inline-flex items-center space-x-2 rounded-full bg-gray-200 px-4 py-2 align-top text-gray-800 transition-colors hover:bg-gray-300 dark:bg-gray-600/60 dark:text-white dark:hover:bg-gray-600 dark:hover:text-gray-200"
>
<FontAwesomeIcon icon={faGithub} className="text-sm" />
<span>Visit GitHub Profile</span>
</a>
</div>
</div>
</div>
<div className="px-6 py-6">
{user.bio && (
<div className="flex items-center space-x-2 text-gray-700 dark:text-gray-300">
<FontAwesomeIcon icon={faCode} className="text-sm" />
arkid15r marked this conversation as resolved.
Show resolved Hide resolved
<p className="text-lg">{user.bio}</p>
</div>
)}

<div className="mt-4 space-y-3">
{user.company && (
<div className="flex items-center space-x-2 text-gray-600 dark:text-gray-400">
<FontAwesomeIcon icon={faBuildingUser} className="text-sm" />
<span>{user.company}</span>
</div>
)}
{user.location && (
<div className="flex items-center space-x-2 text-gray-600 dark:text-gray-400">
<FontAwesomeIcon icon={faLocationDot} className="text-sm" />
<span>{user.location}</span>
</div>
)}
{user.twitter_username && (
<a
href={`https://x.com/${user.twitter_username}`}
target="_blank"
rel="noopener noreferrer"
className="flex items-center space-x-2 text-gray-600 decoration-dotted hover:underline hover:underline-offset-2 dark:text-gray-400"
>
<FontAwesomeIcon icon={faXTwitter} className="text-sm" />
<span>{user.twitter_username}</span>
</a>
)}
{user.email && (
<a
href={`mailto:${user.email}`}
className="flex items-center space-x-2 text-gray-600 decoration-dotted hover:underline hover:underline-offset-2 dark:text-gray-400"
>
<FontAwesomeIcon icon={faEnvelope} className="text-sm" />
<span>{user.email}</span>
</a>
)}
</div>
</div>
<div className="grid grid-cols-3 gap-4 bg-gray-200 p-6 sm:grid-cols-3 dark:bg-gray-900">
{[
{ icon: faUser, label: 'Followers', value: user.followers_count },
{ icon: faUserPlus, label: 'Following', value: user.following_count },
{
icon: faCodeBranch,
label: 'Repositories',
value: user.public_repositories_count,
},
].map(({ icon: Icon, label, value }) => (
<div
key={label}
className="flex flex-col items-center rounded-2xl bg-white p-6 shadow transition-transform hover:scale-105 dark:bg-gray-800"
>
<FontAwesomeIcon
icon={Icon}
className="mb-2 h-8 w-8 text-blue-600 dark:text-blue-400"
/>
<span className="mt-2 text-2xl font-bold text-gray-900 dark:text-white">
{value}
</span>
<span className="text-sm text-gray-500 dark:text-gray-400">{label}</span>
</div>
))}
</div>
<div className="border-t border-gray-200 px-6 py-4 text-center text-sm text-gray-500 dark:border-gray-700 dark:text-gray-400">
Joined{' '}
{new Date(user.created_at).toLocaleDateString('en-US', {
month: 'long',
day: 'numeric',
year: 'numeric',
})}
</div>
</div>
</div>
</div>
)
}

export default UserDetailsPage
2 changes: 2 additions & 0 deletions frontend/src/pages/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import ContributePage from './Contribute'
import Home from './Home'
import ProjectDetailsPage from './ProjectDetails'
import ProjectsPage from './Projects'
import UserDetailsPage from './UserDetails'
export {
Home,
ProjectsPage,
Expand All @@ -19,4 +20,5 @@ export {
CommitteeDetailsPage,
ChapterDetailsPage,
ProjectDetailsPage,
UserDetailsPage,
}
Loading