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', + }, }