Skip to content

Commit 90e8cd5

Browse files
nipunharkid15r
andauthored
Feature/communit snapshots page (OWASP#1130)
* trying out stuff * basic implementation completed * snapshots page completed * test cases added * improved code quality * corrections * mobile navigation implemented * code smell removed * code smell removed * formatting issue * removed trailing spaces * code formatting improved * code formatting improved * test cases updated * updated branch * test cases updated * code structure updated * updated few cases * test coverage completed * code cleanup * nitpicks resolved * formating * updated test case * Update code --------- Co-authored-by: Arkadii Yakovets <2201626+arkid15r@users.noreply.github.com> Co-authored-by: Arkadii Yakovets <arkadii.yakovets@owasp.org>
1 parent ff92652 commit 90e8cd5

File tree

17 files changed

+402
-34
lines changed

17 files changed

+402
-34
lines changed

backend/apps/owasp/graphql/queries/snapshot.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,9 +15,9 @@ class SnapshotQuery(BaseQuery):
1515
key=graphene.String(required=True),
1616
)
1717

18-
recent_snapshots = graphene.List(
18+
snapshots = graphene.List(
1919
SnapshotNode,
20-
limit=graphene.Int(default_value=8),
20+
limit=graphene.Int(default_value=12),
2121
)
2222

2323
def resolve_snapshot(root, info, key):
@@ -27,6 +27,6 @@ def resolve_snapshot(root, info, key):
2727
except Snapshot.DoesNotExist:
2828
return None
2929

30-
def resolve_recent_snapshots(root, info, limit):
31-
"""Resolve recent snapshots."""
30+
def resolve_snapshots(root, info, limit):
31+
"""Resolve snapshots."""
3232
return Snapshot.objects.order_by("-created_at")[:limit]

frontend/__tests__/unit/App.test.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ jest.mock('pages', () => ({
1515
RepositoryDetailsPage: () => (
1616
<div data-testid="repository-details-page">RepositoryDetails Page</div>
1717
),
18+
SnapshotsPage: () => <div data-testid="snapshots-page">Snapshots Page</div>,
1819
SnapshotDetailsPage: () => <div data-testid="snapshot-details-page">SnapshotDetails Page</div>,
1920
UserDetailsPage: () => <div data-testid="user-details-page">UserDetails Page</div>,
2021
UsersPage: () => <div data-testid="users-page">Users Page</div>,

frontend/__tests__/unit/data/mockSnapshotData.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,3 +85,14 @@ export const mockSnapshotDetailsData = {
8585
],
8686
},
8787
}
88+
89+
export const mockSnapshotData = {
90+
snapshots: [
91+
{
92+
title: 'New Snapshot',
93+
key: '2024-12',
94+
startAt: '2024-12-01T00:00:00+00:00',
95+
endAt: '2024-12-31T22:00:30+00:00',
96+
},
97+
],
98+
}
Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
import { useQuery } from '@apollo/client'
2+
import { screen, waitFor, fireEvent } from '@testing-library/react'
3+
import { act } from 'react'
4+
import { useNavigate } from 'react-router-dom'
5+
import { render } from 'wrappers/testUtil'
6+
import { toaster } from 'components/ui/toaster'
7+
import SnapshotsPage from 'pages/Snapshots'
8+
9+
jest.mock('components/ui/toaster', () => ({
10+
toaster: {
11+
create: jest.fn(),
12+
},
13+
}))
14+
15+
jest.mock('react-router-dom', () => ({
16+
...jest.requireActual('react-router-dom'),
17+
useNavigate: jest.fn(),
18+
}))
19+
20+
jest.mock('@apollo/client', () => ({
21+
...jest.requireActual('@apollo/client'),
22+
useQuery: jest.fn(),
23+
}))
24+
25+
const mockSnapshots = [
26+
{
27+
key: '2024-12',
28+
title: 'Snapshot 1',
29+
startAt: '2023-01-01T00:00:00Z',
30+
endAt: '2023-01-02T00:00:00Z',
31+
},
32+
{
33+
key: '2024-11',
34+
title: 'Snapshot 2',
35+
startAt: '2022-12-01T00:00:00Z',
36+
endAt: '2022-12-31T23:59:59Z',
37+
},
38+
]
39+
40+
describe('SnapshotsPage', () => {
41+
beforeEach(() => {
42+
;(useQuery as jest.Mock).mockReturnValue({
43+
data: { snapshots: mockSnapshots },
44+
error: null,
45+
})
46+
})
47+
48+
afterEach(() => {
49+
jest.clearAllMocks()
50+
})
51+
52+
it('renders loading spinner initially', async () => {
53+
;(useQuery as jest.Mock).mockReturnValue({
54+
data: null,
55+
error: null,
56+
})
57+
58+
render(<SnapshotsPage />)
59+
60+
await waitFor(() => {
61+
const loadingSpinners = screen.getAllByAltText('Loading indicator')
62+
expect(loadingSpinners.length).toBe(2)
63+
})
64+
})
65+
66+
it('renders snapshots when data is fetched successfully', async () => {
67+
render(<SnapshotsPage />)
68+
69+
await waitFor(() => {
70+
expect(screen.getByText('Snapshot 1')).toBeInTheDocument()
71+
expect(screen.getByText('Snapshot 2')).toBeInTheDocument()
72+
})
73+
})
74+
75+
it('renders "No Snapshots found" when no snapshots are available', async () => {
76+
;(useQuery as jest.Mock).mockReturnValue({
77+
data: { snapshots: [] },
78+
error: null,
79+
})
80+
81+
render(<SnapshotsPage />)
82+
83+
await waitFor(() => {
84+
expect(screen.getByText('No Snapshots found')).toBeInTheDocument()
85+
})
86+
})
87+
88+
it('shows an error toaster when GraphQL request fails', async () => {
89+
;(useQuery as jest.Mock).mockReturnValue({
90+
data: null,
91+
error: new Error('GraphQL error'),
92+
})
93+
94+
render(<SnapshotsPage />)
95+
96+
await waitFor(() => {
97+
expect(toaster.create).toHaveBeenCalledWith({
98+
description: 'Unable to complete the requested operation.',
99+
title: 'GraphQL Request Failed',
100+
type: 'error',
101+
})
102+
})
103+
})
104+
105+
it('navigates to the correct URL when "View Snapshot" button is clicked', async () => {
106+
const navigateMock = jest.fn()
107+
;(useNavigate as jest.Mock).mockReturnValue(navigateMock)
108+
render(<SnapshotsPage />)
109+
110+
// Wait for the "View Snapshot" button to appear
111+
const viewSnapshotButton = await screen.findAllByRole('button', { name: /view snapshot/i })
112+
113+
// Click the button
114+
await act(async () => {
115+
fireEvent.click(viewSnapshotButton[0])
116+
})
117+
118+
// Check if navigate was called with the correct argument
119+
await waitFor(() => {
120+
expect(navigateMock).toHaveBeenCalledTimes(1)
121+
expect(navigateMock).toHaveBeenCalledWith('/community/snapshots/2024-12')
122+
})
123+
})
124+
})

frontend/src/App.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {
99
ProjectsPage,
1010
RepositoryDetailsPage,
1111
SnapshotDetailsPage,
12+
SnapshotsPage,
1213
UserDetailsPage,
1314
UsersPage,
1415
} from 'pages'
@@ -44,6 +45,7 @@ function App() {
4445
<Route path="/chapters/:chapterKey" element={<ChapterDetailsPage />}></Route>
4546
<Route path="/community/snapshots/:id" element={<SnapshotDetailsPage />}></Route>
4647
<Route path="/community/users" element={<UsersPage />}></Route>
48+
<Route path="/community/snapshots" element={<SnapshotsPage />}></Route>
4749
<Route path="/community/users/:userKey" element={<UserDetailsPage />}></Route>
4850
<Route path="*" element={<ErrorDisplay {...ERROR_CONFIGS['404']} />} />
4951
</Routes>

frontend/src/api/queries/snapshotQueries.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,3 +55,14 @@ export const GET_SNAPSHOT_DETAILS = gql`
5555
}
5656
}
5757
`
58+
59+
export const GET_COMMUNITY_SNAPSHOTS = gql`
60+
query GetCommunitySnapshots {
61+
snapshots(limit: 12) {
62+
key
63+
title
64+
startAt
65+
endAt
66+
}
67+
}
68+
`

frontend/src/components/Header.tsx

Lines changed: 80 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -56,19 +56,48 @@ export default function Header() {
5656
{/* Desktop Header Links */}
5757
<div className="hidden flex-1 justify-between rounded-lg pl-6 font-medium md:block">
5858
<div className="flex justify-start pl-6">
59-
{headerLinks.map((link, i) => (
60-
<NavLink
61-
key={i}
62-
to={link.href}
63-
className={cn(
64-
'navlink px-3 py-2 text-slate-700 hover:text-slate-800 dark:text-slate-300 dark:hover:text-slate-200',
65-
location.pathname === link.href && 'font-bold text-blue-800 dark:text-white'
66-
)}
67-
aria-current="page"
68-
>
69-
{link.text}
70-
</NavLink>
71-
))}
59+
{headerLinks.map((link) => {
60+
return link.submenu ? (
61+
<div
62+
key={link.text}
63+
className={cn(
64+
'dropdown navlink group px-3 py-2 text-slate-700 hover:text-slate-800 dark:text-slate-300 dark:hover:text-slate-200',
65+
link.submenu.map((sub) => sub.href).includes(location.pathname) &&
66+
'font-bold text-blue-800 dark:text-white'
67+
)}
68+
>
69+
{link.text}
70+
<div className="dropdown-menu group-hover:visible group-hover:opacity-100">
71+
{link.submenu.map((sub) => (
72+
<NavLink
73+
key={link.text}
74+
to={sub.href}
75+
className={cn(
76+
'navlink px-3 py-2 text-slate-700 hover:text-slate-800 dark:text-slate-300 dark:hover:text-slate-200',
77+
location.pathname === sub.href &&
78+
'font-bold text-blue-800 dark:text-white'
79+
)}
80+
aria-current="page"
81+
>
82+
{sub.text}
83+
</NavLink>
84+
))}
85+
</div>
86+
</div>
87+
) : (
88+
<NavLink
89+
key={link.text}
90+
to={link.href}
91+
className={cn(
92+
'navlink px-3 py-2 text-slate-700 hover:text-slate-800 dark:text-slate-300 dark:hover:text-slate-200',
93+
location.pathname === link.href && 'font-bold text-blue-800 dark:text-white'
94+
)}
95+
aria-current="page"
96+
>
97+
{link.text}
98+
</NavLink>
99+
)
100+
})}
72101
</div>
73102
</div>
74103
<div className="flex items-center justify-normal space-x-4">
@@ -137,20 +166,44 @@ export default function Header() {
137166
</div>
138167
</div>
139168
</NavLink>
140-
{headerLinks.map((link, i) => (
141-
<NavLink
142-
key={i}
143-
to={link.href}
144-
className={cn(
145-
'navlink block px-3 py-2 text-slate-700 hover:text-slate-800 dark:text-slate-300 dark:hover:text-slate-200',
146-
location.pathname === link.href &&
147-
'font-bold text-blue-800 dark:text-white'
148-
)}
149-
onClick={toggleMobileMenu}
150-
>
151-
{link.text}
152-
</NavLink>
153-
))}
169+
{headerLinks.map((link) =>
170+
link.submenu ? (
171+
<div key={link.text} className="flex flex-col">
172+
<div className="block px-3 py-2 font-medium text-slate-700 dark:text-slate-300">
173+
{link.text}
174+
</div>
175+
<div className="ml-4">
176+
{link.submenu.map((sub) => (
177+
<NavLink
178+
key={link.text}
179+
to={sub.href}
180+
className={cn(
181+
'navlink block px-3 py-2 text-slate-700 hover:text-slate-800 dark:text-slate-300 dark:hover:text-slate-200',
182+
location.pathname === sub.href &&
183+
'font-bold text-blue-800 dark:text-white'
184+
)}
185+
onClick={toggleMobileMenu}
186+
>
187+
{sub.text}
188+
</NavLink>
189+
))}
190+
</div>
191+
</div>
192+
) : (
193+
<NavLink
194+
key={link.text}
195+
to={link.href}
196+
className={cn(
197+
'navlink block px-3 py-2 text-slate-700 hover:text-slate-800 dark:text-slate-300 dark:hover:text-slate-200',
198+
location.pathname === link.href &&
199+
'font-bold text-blue-800 dark:text-white'
200+
)}
201+
onClick={toggleMobileMenu}
202+
>
203+
{link.text}
204+
</NavLink>
205+
)
206+
)}
154207
</div>
155208

156209
<div className="flex flex-col gap-y-2">
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
import { Button } from '@chakra-ui/react'
2+
import { faChevronRight, faCalendar } from '@fortawesome/free-solid-svg-icons'
3+
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
4+
import { SnapshotCardProps } from 'types/card'
5+
import { formatDate } from 'utils/dateFormatter'
6+
7+
const SnapshotCard = ({ title, button, startAt, endAt }: SnapshotCardProps) => {
8+
return (
9+
<Button
10+
onClick={button.onclick}
11+
className="group flex h-40 w-full flex-col items-center rounded-lg bg-white p-6 text-left shadow-lg transition-transform duration-500 hover:scale-105 hover:shadow-xl dark:bg-gray-800 dark:shadow-gray-900/30"
12+
>
13+
<div className="text-center">
14+
<h3 className="max-w-[250px] text-balance text-lg font-semibold text-gray-900 group-hover:text-blue-500 dark:text-white sm:text-xl">
15+
<p>{title}</p>
16+
</h3>
17+
</div>
18+
19+
<div className="flex flex-wrap items-center gap-2 text-gray-600 dark:text-gray-300">
20+
<div className="flex items-center">
21+
<FontAwesomeIcon icon={faCalendar} className="mr-1 h-4 w-4" />
22+
<span>
23+
{formatDate(startAt)} - {formatDate(endAt)}
24+
</span>
25+
</div>
26+
</div>
27+
28+
<div className="mt-auto inline-flex items-center text-sm font-medium text-blue-500 dark:text-blue-400">
29+
View Snapshot
30+
<FontAwesomeIcon
31+
icon={faChevronRight}
32+
className="ml-2 h-4 w-4 transform transition-transform group-hover:translate-x-1"
33+
/>
34+
</div>
35+
</Button>
36+
)
37+
}
38+
39+
export default SnapshotCard

frontend/src/index.css

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,21 @@ a {
118118
color: inherit;
119119
}
120120

121+
/* Dropdown container */
122+
.dropdown {
123+
@apply relative;
124+
}
125+
126+
.dropdown-menu {
127+
@apply absolute left-0 top-full mt-2 w-48 rounded-lg bg-white p-3 shadow-lg dark:bg-gray-800;
128+
@apply invisible opacity-0 transition-all duration-200 ease-in-out;
129+
@apply flex flex-col space-y-2; /* Stack items vertically */
130+
}
131+
132+
.dropdown:hover .dropdown-menu {
133+
@apply visible opacity-100;
134+
}
135+
121136
@keyframes fadeIn {
122137
from {
123138
opacity: 0;

0 commit comments

Comments
 (0)