diff --git a/backend/apps/owasp/graphql/queries/snapshot.py b/backend/apps/owasp/graphql/queries/snapshot.py
index 21560eecd2..2c6e7f4375 100644
--- a/backend/apps/owasp/graphql/queries/snapshot.py
+++ b/backend/apps/owasp/graphql/queries/snapshot.py
@@ -15,9 +15,9 @@ class SnapshotQuery(BaseQuery):
key=graphene.String(required=True),
)
- recent_snapshots = graphene.List(
+ snapshots = graphene.List(
SnapshotNode,
- limit=graphene.Int(default_value=8),
+ limit=graphene.Int(default_value=12),
)
def resolve_snapshot(root, info, key):
@@ -27,6 +27,6 @@ def resolve_snapshot(root, info, key):
except Snapshot.DoesNotExist:
return None
- def resolve_recent_snapshots(root, info, limit):
- """Resolve recent snapshots."""
+ def resolve_snapshots(root, info, limit):
+ """Resolve snapshots."""
return Snapshot.objects.order_by("-created_at")[:limit]
diff --git a/frontend/__tests__/unit/App.test.tsx b/frontend/__tests__/unit/App.test.tsx
index 87ba96bd5a..dcf208b604 100644
--- a/frontend/__tests__/unit/App.test.tsx
+++ b/frontend/__tests__/unit/App.test.tsx
@@ -15,6 +15,7 @@ jest.mock('pages', () => ({
RepositoryDetailsPage: () => (
RepositoryDetails Page
),
+ SnapshotsPage: () => Snapshots Page
,
SnapshotDetailsPage: () => SnapshotDetails Page
,
UserDetailsPage: () => UserDetails Page
,
UsersPage: () => Users Page
,
diff --git a/frontend/__tests__/unit/data/mockSnapshotData.ts b/frontend/__tests__/unit/data/mockSnapshotData.ts
index 937c93e15c..b420ab141c 100644
--- a/frontend/__tests__/unit/data/mockSnapshotData.ts
+++ b/frontend/__tests__/unit/data/mockSnapshotData.ts
@@ -85,3 +85,14 @@ export const mockSnapshotDetailsData = {
],
},
}
+
+export const mockSnapshotData = {
+ snapshots: [
+ {
+ title: 'New Snapshot',
+ key: '2024-12',
+ startAt: '2024-12-01T00:00:00+00:00',
+ endAt: '2024-12-31T22:00:30+00:00',
+ },
+ ],
+}
diff --git a/frontend/__tests__/unit/pages/Snapshots.test.tsx b/frontend/__tests__/unit/pages/Snapshots.test.tsx
new file mode 100644
index 0000000000..1612cfd03c
--- /dev/null
+++ b/frontend/__tests__/unit/pages/Snapshots.test.tsx
@@ -0,0 +1,124 @@
+import { useQuery } from '@apollo/client'
+import { screen, waitFor, fireEvent } from '@testing-library/react'
+import { act } from 'react'
+import { useNavigate } from 'react-router-dom'
+import { render } from 'wrappers/testUtil'
+import { toaster } from 'components/ui/toaster'
+import SnapshotsPage from 'pages/Snapshots'
+
+jest.mock('components/ui/toaster', () => ({
+ toaster: {
+ create: jest.fn(),
+ },
+}))
+
+jest.mock('react-router-dom', () => ({
+ ...jest.requireActual('react-router-dom'),
+ useNavigate: jest.fn(),
+}))
+
+jest.mock('@apollo/client', () => ({
+ ...jest.requireActual('@apollo/client'),
+ useQuery: jest.fn(),
+}))
+
+const mockSnapshots = [
+ {
+ key: '2024-12',
+ title: 'Snapshot 1',
+ startAt: '2023-01-01T00:00:00Z',
+ endAt: '2023-01-02T00:00:00Z',
+ },
+ {
+ key: '2024-11',
+ title: 'Snapshot 2',
+ startAt: '2022-12-01T00:00:00Z',
+ endAt: '2022-12-31T23:59:59Z',
+ },
+]
+
+describe('SnapshotsPage', () => {
+ beforeEach(() => {
+ ;(useQuery as jest.Mock).mockReturnValue({
+ data: { snapshots: mockSnapshots },
+ error: null,
+ })
+ })
+
+ afterEach(() => {
+ jest.clearAllMocks()
+ })
+
+ it('renders loading spinner initially', async () => {
+ ;(useQuery as jest.Mock).mockReturnValue({
+ data: null,
+ error: null,
+ })
+
+ render()
+
+ await waitFor(() => {
+ const loadingSpinners = screen.getAllByAltText('Loading indicator')
+ expect(loadingSpinners.length).toBe(2)
+ })
+ })
+
+ it('renders snapshots when data is fetched successfully', async () => {
+ render()
+
+ await waitFor(() => {
+ expect(screen.getByText('Snapshot 1')).toBeInTheDocument()
+ expect(screen.getByText('Snapshot 2')).toBeInTheDocument()
+ })
+ })
+
+ it('renders "No Snapshots found" when no snapshots are available', async () => {
+ ;(useQuery as jest.Mock).mockReturnValue({
+ data: { snapshots: [] },
+ error: null,
+ })
+
+ render()
+
+ await waitFor(() => {
+ expect(screen.getByText('No Snapshots found')).toBeInTheDocument()
+ })
+ })
+
+ it('shows an error toaster when GraphQL request fails', async () => {
+ ;(useQuery as jest.Mock).mockReturnValue({
+ data: null,
+ error: new Error('GraphQL error'),
+ })
+
+ render()
+
+ await waitFor(() => {
+ expect(toaster.create).toHaveBeenCalledWith({
+ description: 'Unable to complete the requested operation.',
+ title: 'GraphQL Request Failed',
+ type: 'error',
+ })
+ })
+ })
+
+ it('navigates to the correct URL when "View Snapshot" button is clicked', async () => {
+ const navigateMock = jest.fn()
+ ;(useNavigate as jest.Mock).mockReturnValue(navigateMock)
+ render()
+
+ // Wait for the "View Snapshot" button to appear
+ const viewSnapshotButton = await screen.findAllByRole('button', { name: /view snapshot/i })
+
+ // Click the button
+ await act(async () => {
+ fireEvent.click(viewSnapshotButton[0])
+ })
+
+ // Check if navigate was called with the correct argument
+ await waitFor(() => {
+ expect(navigateMock).toHaveBeenCalledTimes(1)
+ expect(navigateMock).toHaveBeenCalledWith('/community/snapshots/2024-12')
+ })
+ })
+})
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index 4e7da678a3..fcaf706618 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -9,6 +9,7 @@ import {
ProjectsPage,
RepositoryDetailsPage,
SnapshotDetailsPage,
+ SnapshotsPage,
UserDetailsPage,
UsersPage,
} from 'pages'
@@ -44,6 +45,7 @@ function App() {
}>
}>
}>
+ }>
}>
} />
diff --git a/frontend/src/api/queries/snapshotQueries.ts b/frontend/src/api/queries/snapshotQueries.ts
index 342fd53939..a69d6fcc0a 100644
--- a/frontend/src/api/queries/snapshotQueries.ts
+++ b/frontend/src/api/queries/snapshotQueries.ts
@@ -55,3 +55,14 @@ export const GET_SNAPSHOT_DETAILS = gql`
}
}
`
+
+export const GET_COMMUNITY_SNAPSHOTS = gql`
+ query GetCommunitySnapshots {
+ snapshots(limit: 12) {
+ key
+ title
+ startAt
+ endAt
+ }
+ }
+`
diff --git a/frontend/src/components/Header.tsx b/frontend/src/components/Header.tsx
index bcbed52c20..1ccaaab24c 100644
--- a/frontend/src/components/Header.tsx
+++ b/frontend/src/components/Header.tsx
@@ -56,19 +56,48 @@ export default function Header() {
{/* Desktop Header Links */}
- {headerLinks.map((link, i) => (
-
- {link.text}
-
- ))}
+ {headerLinks.map((link) => {
+ return link.submenu ? (
+
sub.href).includes(location.pathname) &&
+ 'font-bold text-blue-800 dark:text-white'
+ )}
+ >
+ {link.text}
+
+ {link.submenu.map((sub) => (
+
+ {sub.text}
+
+ ))}
+
+
+ ) : (
+
+ {link.text}
+
+ )
+ })}
@@ -137,20 +166,44 @@ export default function Header() {
- {headerLinks.map((link, i) => (
-
- {link.text}
-
- ))}
+ {headerLinks.map((link) =>
+ link.submenu ? (
+
+
+ {link.text}
+
+
+ {link.submenu.map((sub) => (
+
+ {sub.text}
+
+ ))}
+
+
+ ) : (
+
+ {link.text}
+
+ )
+ )}
diff --git a/frontend/src/components/SnapshotCard.tsx b/frontend/src/components/SnapshotCard.tsx
new file mode 100644
index 0000000000..ac24fd301a
--- /dev/null
+++ b/frontend/src/components/SnapshotCard.tsx
@@ -0,0 +1,39 @@
+import { Button } from '@chakra-ui/react'
+import { faChevronRight, faCalendar } from '@fortawesome/free-solid-svg-icons'
+import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
+import { SnapshotCardProps } from 'types/card'
+import { formatDate } from 'utils/dateFormatter'
+
+const SnapshotCard = ({ title, button, startAt, endAt }: SnapshotCardProps) => {
+ return (
+
+ )
+}
+
+export default SnapshotCard
diff --git a/frontend/src/index.css b/frontend/src/index.css
index f46f2a0e22..a5d134e04f 100644
--- a/frontend/src/index.css
+++ b/frontend/src/index.css
@@ -118,6 +118,21 @@ a {
color: inherit;
}
+/* Dropdown container */
+.dropdown {
+ @apply relative;
+}
+
+.dropdown-menu {
+ @apply absolute left-0 top-full mt-2 w-48 rounded-lg bg-white p-3 shadow-lg dark:bg-gray-800;
+ @apply invisible opacity-0 transition-all duration-200 ease-in-out;
+ @apply flex flex-col space-y-2; /* Stack items vertically */
+}
+
+.dropdown:hover .dropdown-menu {
+ @apply visible opacity-100;
+}
+
@keyframes fadeIn {
from {
opacity: 0;
diff --git a/frontend/src/pages/Snapshots.tsx b/frontend/src/pages/Snapshots.tsx
new file mode 100644
index 0000000000..1f8262839c
--- /dev/null
+++ b/frontend/src/pages/Snapshots.tsx
@@ -0,0 +1,84 @@
+import { useQuery } from '@apollo/client'
+import { GET_COMMUNITY_SNAPSHOTS } from 'api/queries/snapshotQueries'
+import React, { useState, useEffect } from 'react'
+import { useNavigate } from 'react-router-dom'
+import { Snapshots } from 'types/snapshot'
+import { METADATA_CONFIG } from 'utils/metadata'
+import FontAwesomeIconWrapper from 'wrappers/FontAwesomeIconWrapper'
+import LoadingSpinner from 'components/LoadingSpinner'
+import MetadataManager from 'components/MetadataManager'
+import SnapshotCard from 'components/SnapshotCard'
+import { toaster } from 'components/ui/toaster'
+
+const SnapshotsPage: React.FC = () => {
+ const [snapshots, setSnapshots] = useState
(null)
+ const [isLoading, setIsLoading] = useState(true)
+
+ const { data: graphQLData, error: graphQLRequestError } = useQuery(GET_COMMUNITY_SNAPSHOTS)
+
+ useEffect(() => {
+ if (graphQLData) {
+ setSnapshots(graphQLData?.snapshots)
+ setIsLoading(false)
+ }
+ if (graphQLRequestError) {
+ toaster.create({
+ description: 'Unable to complete the requested operation.',
+ title: 'GraphQL Request Failed',
+ type: 'error',
+ })
+ setIsLoading(false)
+ }
+ }, [graphQLData, graphQLRequestError])
+
+ const navigate = useNavigate()
+
+ const handleButtonClick = (snapshot: Snapshots) => {
+ navigate(`/community/snapshots/${snapshot.key}`)
+ }
+
+ const renderSnapshotCard = (snapshot: Snapshots) => {
+ const SubmitButton = {
+ label: 'View Details',
+ icon: ,
+ onclick: () => handleButtonClick(snapshot),
+ }
+
+ return (
+
+ )
+ }
+
+ if (isLoading) {
+ return (
+
+
+
+ )
+ }
+ return (
+
+
+
+
+ {!snapshots?.length ? (
+
No Snapshots found
+ ) : (
+ snapshots.map((snapshot: Snapshots) => (
+
{renderSnapshotCard(snapshot)}
+ ))
+ )}
+
+
+
+
+ )
+}
+
+export default SnapshotsPage
diff --git a/frontend/src/pages/index.ts b/frontend/src/pages/index.ts
index 9ba05d871b..6a3d83bebb 100644
--- a/frontend/src/pages/index.ts
+++ b/frontend/src/pages/index.ts
@@ -12,6 +12,7 @@ import ProjectDetailsPage from './ProjectDetails'
import ProjectsPage from './Projects'
import RepositoryDetailsPage from './RepositoryDetails'
import SnapshotDetailsPage from './SnapshotDetails'
+import SnapshotsPage from './Snapshots'
import UserDetailsPage from './UserDetails'
import UsersPage from './Users'
export {
@@ -24,6 +25,7 @@ export {
ProjectDetailsPage,
ProjectsPage,
RepositoryDetailsPage,
+ SnapshotsPage,
SnapshotDetailsPage,
UserDetailsPage,
UsersPage,
diff --git a/frontend/src/types/card.ts b/frontend/src/types/card.ts
index be453d8836..b8c704e875 100644
--- a/frontend/src/types/card.ts
+++ b/frontend/src/types/card.ts
@@ -57,3 +57,11 @@ export interface UserCardProps {
location: string
name: string
}
+
+export interface SnapshotCardProps {
+ key: string
+ startAt: string
+ endAt: string
+ title: string
+ button: ButtonType
+}
diff --git a/frontend/src/types/link.ts b/frontend/src/types/link.ts
index 23f54fdfd1..62b398dfbb 100644
--- a/frontend/src/types/link.ts
+++ b/frontend/src/types/link.ts
@@ -1,5 +1,6 @@
export interface Link {
- text: string
- href: string
+ href?: string
isSpan?: boolean
+ submenu?: Link[]
+ text: string
}
diff --git a/frontend/src/types/seo.ts b/frontend/src/types/seo.ts
index 00225a6d83..34214f350a 100644
--- a/frontend/src/types/seo.ts
+++ b/frontend/src/types/seo.ts
@@ -23,6 +23,7 @@ export interface MetadataConfig {
home: PageMetadata
projectContribute: PageMetadata
projects: PageMetadata
+ snapshot: PageMetadata
users: PageMetadata
}
diff --git a/frontend/src/types/snapshot.ts b/frontend/src/types/snapshot.ts
index 57fcd4a862..8ba77ec100 100644
--- a/frontend/src/types/snapshot.ts
+++ b/frontend/src/types/snapshot.ts
@@ -17,3 +17,10 @@ export interface SnapshotDetailsProps {
newProjects: ProjectTypeGraphql[]
newChapters: ChapterTypeGraphQL[]
}
+
+export interface Snapshots {
+ endAt: string
+ key: string
+ startAt: string
+ title: string
+}
diff --git a/frontend/src/utils/constants.ts b/frontend/src/utils/constants.ts
index 4dce56b16f..8265306e5c 100644
--- a/frontend/src/utils/constants.ts
+++ b/frontend/src/utils/constants.ts
@@ -12,7 +12,10 @@ export const headerLinks: Link[] = [
},
{
text: 'Community',
- href: '/community/users',
+ submenu: [
+ { text: 'Snapshots', href: '/community/snapshots' },
+ { text: 'Users', href: '/community/users' },
+ ],
},
{
text: 'Chapters',
diff --git a/frontend/src/utils/metadata.ts b/frontend/src/utils/metadata.ts
index 7906d52202..cba6b71a58 100644
--- a/frontend/src/utils/metadata.ts
+++ b/frontend/src/utils/metadata.ts
@@ -38,4 +38,10 @@ export const METADATA_CONFIG: MetadataConfig = {
pageTitle: 'Contribute',
type: 'website',
},
+ snapshot: {
+ description: 'Snapshots of OWASP',
+ keywords: ['OWASP snapshot', 'contribute'],
+ pageTitle: 'Snapshots',
+ type: 'website',
+ },
}