diff --git a/backend/apps/github/graphql/nodes/release.py b/backend/apps/github/graphql/nodes/release.py
index 75c20d9fe..f4af7888f 100644
--- a/backend/apps/github/graphql/nodes/release.py
+++ b/backend/apps/github/graphql/nodes/release.py
@@ -1,16 +1,18 @@
"""GitHub release GraphQL node."""
-from graphene import Field
+import graphene
from apps.common.graphql.nodes import BaseNode
from apps.github.graphql.nodes.user import UserNode
from apps.github.models.release import Release
+from apps.owasp.constants import OWASP_ORGANIZATION_NAME
class ReleaseNode(BaseNode):
"""GitHub release node."""
- author = Field(UserNode)
+ author = graphene.Field(UserNode)
+ project_name = graphene.String()
class Meta:
model = Release
@@ -21,3 +23,7 @@ class Meta:
"published_at",
"tag_name",
)
+
+ def resolve_project_name(self, info):
+ """Return project name."""
+ return self.repository.project.name.lstrip(OWASP_ORGANIZATION_NAME)
diff --git a/backend/apps/owasp/admin.py b/backend/apps/owasp/admin.py
index d09a25041..3bdf2a75d 100644
--- a/backend/apps/owasp/admin.py
+++ b/backend/apps/owasp/admin.py
@@ -120,6 +120,7 @@ class SnapshotAdmin(admin.ModelAdmin):
"new_users",
)
list_display = (
+ "title",
"start_at",
"end_at",
"status",
@@ -133,6 +134,8 @@ class SnapshotAdmin(admin.ModelAdmin):
)
ordering = ("-start_at",)
search_fields = (
+ "title",
+ "key",
"status",
"error_message",
)
diff --git a/backend/apps/owasp/graphql/nodes/snapshot.py b/backend/apps/owasp/graphql/nodes/snapshot.py
new file mode 100644
index 000000000..53c15fd78
--- /dev/null
+++ b/backend/apps/owasp/graphql/nodes/snapshot.py
@@ -0,0 +1,57 @@
+"""OWASP snapshot GraphQL node."""
+
+import graphene
+
+from apps.github.graphql.nodes.issue import IssueNode
+from apps.github.graphql.nodes.release import ReleaseNode
+from apps.github.graphql.nodes.user import UserNode
+from apps.owasp.graphql.nodes.chapter import ChapterNode
+from apps.owasp.graphql.nodes.common import GenericEntityNode
+from apps.owasp.graphql.nodes.project import ProjectNode
+from apps.owasp.models.snapshot import Snapshot
+
+RECENT_ISSUES_LIMIT = 100
+
+
+class SnapshotNode(GenericEntityNode):
+ """Snapshot node."""
+
+ key = graphene.String()
+ new_chapters = graphene.List(ChapterNode)
+ new_issues = graphene.List(IssueNode)
+ new_projects = graphene.List(ProjectNode)
+ new_releases = graphene.List(ReleaseNode)
+ new_users = graphene.List(UserNode)
+
+ class Meta:
+ model = Snapshot
+ fields = (
+ "created_at",
+ "end_at",
+ "start_at",
+ "title",
+ )
+
+ def resolve_key(self, info):
+ """Resolve key."""
+ return self.key
+
+ def resolve_new_chapters(self, info):
+ """Resolve new chapters."""
+ return self.new_chapters.all()
+
+ def resolve_new_issues(self, info):
+ """Resolve recent new issues."""
+ return self.new_issues.order_by("-created_at")[:RECENT_ISSUES_LIMIT]
+
+ def resolve_new_projects(self, info):
+ """Resolve recent new projects."""
+ return self.new_projects.order_by("-created_at")
+
+ def resolve_new_releases(self, info):
+ """Resolve recent new releases."""
+ return self.new_releases.order_by("-published_at")
+
+ def resolve_new_users(self, info):
+ """Resolve recent new users."""
+ return self.new_users.order_by("-created_at")
diff --git a/backend/apps/owasp/graphql/queries/__init__.py b/backend/apps/owasp/graphql/queries/__init__.py
index b921a9d79..871bd1c3d 100644
--- a/backend/apps/owasp/graphql/queries/__init__.py
+++ b/backend/apps/owasp/graphql/queries/__init__.py
@@ -4,8 +4,11 @@
from .committee import CommitteeQuery
from .event import EventQuery
from .project import ProjectQuery
+from .snapshot import SnapshotQuery
from .stats import StatsQuery
-class OwaspQuery(ChapterQuery, CommitteeQuery, EventQuery, ProjectQuery, StatsQuery):
+class OwaspQuery(
+ ChapterQuery, CommitteeQuery, EventQuery, ProjectQuery, SnapshotQuery, StatsQuery
+):
"""OWASP queries."""
diff --git a/backend/apps/owasp/graphql/queries/snapshot.py b/backend/apps/owasp/graphql/queries/snapshot.py
new file mode 100644
index 000000000..21560eecd
--- /dev/null
+++ b/backend/apps/owasp/graphql/queries/snapshot.py
@@ -0,0 +1,32 @@
+"""OWASP snapshot GraphQL queries."""
+
+import graphene
+
+from apps.common.graphql.queries import BaseQuery
+from apps.owasp.graphql.nodes.snapshot import SnapshotNode
+from apps.owasp.models.snapshot import Snapshot
+
+
+class SnapshotQuery(BaseQuery):
+ """Snapshot queries."""
+
+ snapshot = graphene.Field(
+ SnapshotNode,
+ key=graphene.String(required=True),
+ )
+
+ recent_snapshots = graphene.List(
+ SnapshotNode,
+ limit=graphene.Int(default_value=8),
+ )
+
+ def resolve_snapshot(root, info, key):
+ """Resolve snapshot by key."""
+ try:
+ return Snapshot.objects.get(key=key)
+ except Snapshot.DoesNotExist:
+ return None
+
+ def resolve_recent_snapshots(root, info, limit):
+ """Resolve recent snapshots."""
+ return Snapshot.objects.order_by("-created_at")[:limit]
diff --git a/backend/apps/owasp/migrations/0020_snapshot_key_snapshot_title.py b/backend/apps/owasp/migrations/0020_snapshot_key_snapshot_title.py
new file mode 100644
index 000000000..5fc8e7b57
--- /dev/null
+++ b/backend/apps/owasp/migrations/0020_snapshot_key_snapshot_title.py
@@ -0,0 +1,23 @@
+# Generated by Django 5.1.6 on 2025-03-03 02:18
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+ dependencies = [
+ ("owasp", "0019_alter_event_category"),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name="snapshot",
+ name="key",
+ field=models.CharField(default="", max_length=10, unique=True),
+ preserve_default=False,
+ ),
+ migrations.AddField(
+ model_name="snapshot",
+ name="title",
+ field=models.CharField(default="", max_length=255),
+ ),
+ ]
diff --git a/backend/apps/owasp/migrations/0021_alter_snapshot_key.py b/backend/apps/owasp/migrations/0021_alter_snapshot_key.py
new file mode 100644
index 000000000..7913ceb1f
--- /dev/null
+++ b/backend/apps/owasp/migrations/0021_alter_snapshot_key.py
@@ -0,0 +1,17 @@
+# Generated by Django 5.1.6 on 2025-03-03 02:23
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+ dependencies = [
+ ("owasp", "0020_snapshot_key_snapshot_title"),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name="snapshot",
+ name="key",
+ field=models.CharField(blank=True, max_length=10, unique=True),
+ ),
+ ]
diff --git a/backend/apps/owasp/models/snapshot.py b/backend/apps/owasp/models/snapshot.py
index c509dbacf..684fe66ed 100644
--- a/backend/apps/owasp/models/snapshot.py
+++ b/backend/apps/owasp/models/snapshot.py
@@ -1,6 +1,7 @@
"""OWASP app snapshot models."""
from django.db import models
+from django.utils.timezone import now
class Snapshot(models.Model):
@@ -16,6 +17,9 @@ class Status(models.TextChoices):
COMPLETED = "completed", "Completed"
ERROR = "error", "Error"
+ title = models.CharField(max_length=255, default="")
+ key = models.CharField(max_length=10, unique=True, blank=True)
+
created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True)
@@ -33,4 +37,11 @@ class Status(models.TextChoices):
def __str__(self):
"""Return a string representation of the snapshot."""
- return f"Snapshot {self.start_at} to {self.end_at} ({self.status})"
+ return self.title
+
+ def save(self, *args, **kwargs):
+ """Save snapshot."""
+ if not self.key: # automatically set the key
+ self.key = now().strftime("%Y-%m")
+
+ super().save(*args, **kwargs)
diff --git a/backend/tests/github/graphql/nodes/release_test.py b/backend/tests/github/graphql/nodes/release_test.py
index d8f9f018b..919287189 100644
--- a/backend/tests/github/graphql/nodes/release_test.py
+++ b/backend/tests/github/graphql/nodes/release_test.py
@@ -22,6 +22,7 @@ def test_meta_configuration(self):
"author",
"is_pre_release",
"name",
+ "project_name",
"published_at",
"tag_name",
}
diff --git a/backend/tests/owasp/models/snapshot_test.py b/backend/tests/owasp/models/snapshot_test.py
index 6dcf6b8ba..5e89f480b 100644
--- a/backend/tests/owasp/models/snapshot_test.py
+++ b/backend/tests/owasp/models/snapshot_test.py
@@ -12,6 +12,8 @@ def setUp(self):
"""Set up a mocked snapshot object."""
self.snapshot = MagicMock(spec=Snapshot) # Mock entire model
self.snapshot.id = 1 # Set an ID to avoid ManyToMany errors
+ self.snapshot.title = "Mock Snapshot Title"
+ self.snapshot.key = "2025-02"
self.snapshot.start_at = "2025-02-21"
self.snapshot.end_at = "2025-02-21"
self.snapshot.status = Snapshot.Status.PROCESSING
@@ -27,3 +29,8 @@ def test_mocked_many_to_many_relations(self):
"""Test ManyToMany relationships using mocks."""
self.snapshot.new_chapters.set(["Mock Chapter"])
self.snapshot.new_chapters.set.assert_called_once_with(["Mock Chapter"])
+
+ def test_snapshot_attributes(self):
+ """Test that title and key are correctly assigned."""
+ assert self.snapshot.title == "Mock Snapshot Title"
+ assert self.snapshot.key == "2025-02"
diff --git a/frontend/__tests__/unit/App.test.tsx b/frontend/__tests__/unit/App.test.tsx
index 45c05c2f2..d762a8b86 100644
--- a/frontend/__tests__/unit/App.test.tsx
+++ b/frontend/__tests__/unit/App.test.tsx
@@ -17,6 +17,7 @@ jest.mock('pages', () => ({
RepositoryDetailsPage: () => (
RepositoryDetails 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
new file mode 100644
index 000000000..937c93e15
--- /dev/null
+++ b/frontend/__tests__/unit/data/mockSnapshotData.ts
@@ -0,0 +1,87 @@
+export const mockSnapshotDetailsData = {
+ snapshot: {
+ title: 'New Snapshot',
+ key: '2024-12',
+ updatedAt: '2025-03-02T20:33:46.880330+00:00',
+ createdAt: '2025-03-01T22:00:34.361937+00:00',
+ startAt: '2024-12-01T00:00:00+00:00',
+ endAt: '2024-12-31T22:00:30+00:00',
+ status: 'completed',
+ errorMessage: '',
+ newReleases: [
+ {
+ name: 'v0.9.2',
+ publishedAt: '2024-12-13T14:43:46+00:00',
+ tagName: 'v0.9.2',
+ projectName: 'test-project-1',
+ },
+ {
+ name: 'Latest pre-release',
+ publishedAt: '2024-12-13T13:17:30+00:00',
+ tagName: 'pre-release',
+ projectName: 'test-project-2',
+ },
+ ],
+ newProjects: [
+ {
+ key: 'nest',
+ name: 'OWASP Nest',
+ summary:
+ 'OWASP Nest is a code project aimed at improving how OWASP manages its collection of projects...',
+ starsCount: 14,
+ forksCount: 19,
+ contributorsCount: 14,
+ level: 'INCUBATOR',
+ isActive: true,
+ repositoriesCount: 2,
+ topContributors: [
+ {
+ avatarUrl: 'https://avatars.githubusercontent.com/u/2201626?v=4',
+ contributionsCount: 170,
+ login: 'arkid15r',
+ name: 'Arkadii Yakovets',
+ },
+ {
+ avatarUrl: 'https://avatars.githubusercontent.com/u/97700473?v=4',
+ contributionsCount: 5,
+ login: 'test-user',
+ name: 'test user',
+ },
+ ],
+ },
+ ],
+ newChapters: [
+ {
+ key: 'sivagangai',
+ name: 'OWASP Sivagangai',
+ createdAt: '2024-07-30T10:07:33+00:00',
+ suggestedLocation: 'Sivagangai, Tamil Nadu, India',
+ region: 'Asia',
+ summary:
+ 'OWASP Sivagangai is a new local chapter that focuses on AI and application security...',
+ topContributors: [
+ {
+ avatarUrl: 'https://avatars.githubusercontent.com/u/95969896?v=4',
+ contributionsCount: 14,
+ login: 'acs-web-tech',
+ name: 'P.ARUN',
+ },
+ {
+ avatarUrl: 'https://avatars.githubusercontent.com/u/56408064?v=4',
+ contributionsCount: 1,
+ login: 'test-user-1',
+ name: '',
+ },
+ ],
+ updatedAt: 1727353371.0,
+ url: 'https://owasp.org/www-chapter-sivagangai',
+ relatedUrls: [],
+ geoLocation: {
+ lat: 9.9650599,
+ lng: 78.7204283237222,
+ },
+ isActive: true,
+ },
+ ],
+ },
+}
diff --git a/frontend/__tests__/unit/pages/SnapshotDetails.test.tsx b/frontend/__tests__/unit/pages/SnapshotDetails.test.tsx
new file mode 100644
index 000000000..08bf9c4d3
--- /dev/null
+++ b/frontend/__tests__/unit/pages/SnapshotDetails.test.tsx
@@ -0,0 +1,169 @@
+import { useQuery } from '@apollo/client'
+import { fireEvent, screen, waitFor } from '@testing-library/react'
+import { mockSnapshotDetailsData } from '@unit/data/mockSnapshotData'
+import { toast } from 'hooks/useToast'
+import { SnapshotDetailsPage } from 'pages'
+import { useNavigate } from 'react-router-dom'
+import { render } from 'wrappers/testUtil'
+
+jest.mock('hooks/useToast', () => ({
+ toast: jest.fn(),
+}))
+
+jest.mock('@apollo/client', () => ({
+ ...jest.requireActual('@apollo/client'),
+ useQuery: jest.fn(),
+}))
+
+jest.mock('react-router-dom', () => ({
+ ...jest.requireActual('react-router-dom'),
+ useParams: () => ({ id: '2024-12' }),
+ useNavigate: jest.fn(),
+}))
+
+const mockError = {
+ error: new Error('GraphQL error'),
+}
+
+describe('SnapshotDetailsPage', () => {
+ let navigateMock: jest.Mock
+
+ beforeEach(() => {
+ navigateMock = jest.fn()
+ ;(useQuery as jest.Mock).mockReturnValue({
+ data: mockSnapshotDetailsData,
+ loading: false,
+ error: null,
+ })
+ ;(useNavigate as jest.Mock).mockImplementation(() => navigateMock)
+ })
+
+ afterEach(() => {
+ jest.clearAllMocks()
+ })
+
+ test('renders loading state', async () => {
+ ;(useQuery as jest.Mock).mockReturnValue({
+ data: null,
+ loading: true,
+ error: null,
+ })
+
+ render()
+
+ const loadingSpinner = screen.getAllByAltText('Loading indicator')
+ await waitFor(() => {
+ expect(loadingSpinner.length).toBeGreaterThan(0)
+ })
+ })
+
+ test('renders snapshot details when data is available', async () => {
+ ;(useQuery as jest.Mock).mockReturnValue({
+ data: mockSnapshotDetailsData,
+ error: null,
+ })
+
+ render()
+
+ await waitFor(() => {
+ expect(screen.getByText('New Snapshot')).toBeInTheDocument()
+ })
+
+ expect(screen.getByText('New Chapters')).toBeInTheDocument()
+ expect(screen.getByText('New Projects')).toBeInTheDocument()
+ expect(screen.getByText('New Releases')).toBeInTheDocument()
+ })
+
+ test('renders error message when GraphQL request fails', async () => {
+ ;(useQuery as jest.Mock).mockReturnValue({
+ data: null,
+ error: mockError,
+ })
+
+ render()
+
+ await waitFor(() => screen.getByText('Snapshot not found'))
+ expect(screen.getByText('Snapshot not found')).toBeInTheDocument()
+ expect(toast).toHaveBeenCalledWith({
+ description: 'Unable to complete the requested operation.',
+ title: 'GraphQL Request Failed',
+ variant: 'destructive',
+ })
+ })
+
+ test('navigates to project page when project card is clicked', async () => {
+ ;(useQuery as jest.Mock).mockReturnValue({
+ data: mockSnapshotDetailsData,
+ })
+
+ render()
+
+ await waitFor(() => {
+ expect(screen.getByText('OWASP Nest')).toBeInTheDocument()
+ })
+
+ const projectCardButton = screen.getAllByRole('button', { name: /View Details/i })[1]
+ fireEvent.click(projectCardButton)
+
+ await waitFor(() => {
+ expect(navigateMock).toHaveBeenCalledWith('/projects/nest')
+ })
+ })
+
+ test('navigates to chapter page when chapter card is clicked', async () => {
+ ;(useQuery as jest.Mock).mockReturnValue({
+ data: mockSnapshotDetailsData,
+ })
+
+ render()
+
+ await waitFor(() => {
+ expect(screen.getByText('OWASP Sivagangai')).toBeInTheDocument()
+ })
+
+ const chapterCardButton = screen.getAllByRole('button', { name: /View Details/i })[0]
+ fireEvent.click(chapterCardButton)
+
+ await waitFor(() => {
+ expect(navigateMock).toHaveBeenCalledWith('/chapters/sivagangai')
+ })
+ })
+
+ test('renders new releases correctly', async () => {
+ ;(useQuery as jest.Mock).mockReturnValue({
+ data: mockSnapshotDetailsData,
+ })
+
+ render()
+
+ await waitFor(() => {
+ expect(screen.getByText('New Snapshot')).toBeInTheDocument()
+ expect(screen.getByText('Latest pre-release')).toBeInTheDocument()
+ })
+
+ expect(screen.getByText('test-project-1')).toBeInTheDocument()
+ expect(screen.getByText('test-project-2')).toBeInTheDocument()
+ })
+
+ test('handles missing data gracefully', async () => {
+ ;(useQuery as jest.Mock).mockReturnValue({
+ data: {
+ snapshot: {
+ ...mockSnapshotDetailsData.snapshot,
+ newChapters: [],
+ newProjects: [],
+ newReleases: [],
+ },
+ },
+ error: null,
+ })
+
+ render()
+
+ await waitFor(() => {
+ expect(screen.queryByText('New Chapters')).not.toBeInTheDocument()
+ expect(screen.queryByText('New Projects')).not.toBeInTheDocument()
+ expect(screen.queryByText('New Releases')).not.toBeInTheDocument()
+ })
+ })
+})
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index a76457eb1..0a7b3f0d2 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -1,15 +1,16 @@
import {
- Home,
- ProjectsPage,
- CommitteesPage,
+ ChapterDetailsPage,
ChaptersPage,
+ CommitteeDetailsPage,
+ CommitteesPage,
ContributePage,
+ Home,
ProjectDetailsPage,
- CommitteeDetailsPage,
- ChapterDetailsPage,
- UsersPage,
- UserDetailsPage,
+ ProjectsPage,
RepositoryDetailsPage,
+ SnapshotDetailsPage,
+ UserDetailsPage,
+ UsersPage,
} from 'pages'
import { useEffect } from 'react'
import { Routes, Route, useLocation } from 'react-router-dom'
@@ -43,6 +44,7 @@ function App() {
}>
}>
}>
+ }>
}>
}>
} />
diff --git a/frontend/src/api/queries/snapshotQueries.ts b/frontend/src/api/queries/snapshotQueries.ts
new file mode 100644
index 000000000..342fd5393
--- /dev/null
+++ b/frontend/src/api/queries/snapshotQueries.ts
@@ -0,0 +1,57 @@
+import { gql } from '@apollo/client'
+
+export const GET_SNAPSHOT_DETAILS = gql`
+ query GetSnapshotDetails($key: String!) {
+ snapshot(key: $key) {
+ endAt
+ key
+ startAt
+ title
+ newReleases {
+ name
+ publishedAt
+ tagName
+ projectName
+ }
+ newProjects {
+ key
+ name
+ summary
+ starsCount
+ forksCount
+ contributorsCount
+ level
+ isActive
+ repositoriesCount
+ topContributors {
+ avatarUrl
+ contributionsCount
+ login
+ name
+ }
+ }
+ newChapters {
+ key
+ name
+ createdAt
+ suggestedLocation
+ region
+ summary
+ topContributors {
+ avatarUrl
+ contributionsCount
+ login
+ name
+ }
+ updatedAt
+ url
+ relatedUrls
+ geoLocation {
+ lat
+ lng
+ }
+ isActive
+ }
+ }
+ }
+`
diff --git a/frontend/src/components/ContributorAvatar.tsx b/frontend/src/components/ContributorAvatar.tsx
index 0bfed42a1..78cf2b3f0 100644
--- a/frontend/src/components/ContributorAvatar.tsx
+++ b/frontend/src/components/ContributorAvatar.tsx
@@ -1,28 +1,55 @@
import { Link } from '@chakra-ui/react'
import { memo } from 'react'
-import { TopContributorsTypeAlgolia } from 'types/contributor'
+import { TopContributorsTypeAlgolia, TopContributorsTypeGraphql } from 'types/contributor'
import { Tooltip } from 'components/ui/tooltip'
-const ContributorAvatar = memo(({ contributor }: { contributor: TopContributorsTypeAlgolia }) => {
- const displayName = contributor.name || contributor.login
+type ContributorProps = {
+ contributor: TopContributorsTypeAlgolia | TopContributorsTypeGraphql
+}
+
+const isAlgoliaContributor = (
+ contributor: TopContributorsTypeAlgolia | TopContributorsTypeGraphql
+): contributor is TopContributorsTypeAlgolia => {
+ return (
+ typeof contributor === 'object' &&
+ contributor !== null &&
+ 'avatar_url' in contributor &&
+ 'contributions_count' in contributor
+ )
+}
+
+const ContributorAvatar = memo(({ contributor }: ContributorProps) => {
+ const isAlgolia = isAlgoliaContributor(contributor)
+
+ const avatarUrl = isAlgolia
+ ? contributor.avatar_url
+ : (contributor as TopContributorsTypeGraphql).avatarUrl
+
+ const contributionsCount = isAlgolia
+ ? contributor.contributions_count
+ : (contributor as TopContributorsTypeGraphql).contributionsCount
+
+ const { login, name } = contributor
+ const displayName = name || login
+
+ const repositoryInfo =
+ !isAlgolia && (contributor as TopContributorsTypeGraphql).repositoryName
+ ? ` in ${(contributor as TopContributorsTypeGraphql).repositoryName}`
+ : ''
return (
-
+
@@ -30,4 +57,6 @@ const ContributorAvatar = memo(({ contributor }: { contributor: TopContributorsT
)
})
+ContributorAvatar.displayName = 'ContributorAvatar'
+
export default ContributorAvatar
diff --git a/frontend/src/components/DisplayIcon.tsx b/frontend/src/components/DisplayIcon.tsx
index e02b8e721..2b3af2e3e 100644
--- a/frontend/src/components/DisplayIcon.tsx
+++ b/frontend/src/components/DisplayIcon.tsx
@@ -9,8 +9,13 @@ export default function DisplayIcon({ item, icons }: { item: string; icons: Icon
// className for the container
const containerClassName = [
'flex flex-row-reverse items-center justify-center gap-1 px-4 pb-1 -ml-2',
- item === 'stars_count' ? 'rotate-container' : '',
- item === 'forks_count' || item === 'contributors_count' ? 'flip-container' : '',
+ item === 'stars_count' || item === 'starsCount' ? 'rotate-container' : '',
+ item === 'forks_count' ||
+ item === 'contributors_count' ||
+ item === 'forksCount' ||
+ item === 'contributionCount'
+ ? 'flip-container'
+ : '',
]
.filter(Boolean)
.join(' ')
@@ -18,8 +23,13 @@ export default function DisplayIcon({ item, icons }: { item: string; icons: Icon
// className for the FontAwesome icon
const iconClassName = [
'text-gray-600 dark:text-gray-300',
- item === 'stars_count' ? 'icon-rotate' : '',
- item === 'forks_count' || item === 'contributors_count' ? 'icon-flip' : '',
+ item === 'stars_count' || item === 'starsCount' ? 'icon-rotate' : '',
+ item === 'forks_count' ||
+ item === 'contributors_count' ||
+ item === 'forksCount' ||
+ item === 'contributionCount'
+ ? 'icon-flip'
+ : '',
]
.filter(Boolean)
.join(' ')
diff --git a/frontend/src/pages/SnapshotDetails.tsx b/frontend/src/pages/SnapshotDetails.tsx
new file mode 100644
index 000000000..405b00705
--- /dev/null
+++ b/frontend/src/pages/SnapshotDetails.tsx
@@ -0,0 +1,207 @@
+import { useQuery } from '@apollo/client'
+import { faCalendar } from '@fortawesome/free-solid-svg-icons'
+import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
+import { GET_SNAPSHOT_DETAILS } from 'api/queries/snapshotQueries'
+import { toast } from 'hooks/useToast'
+import React, { useState, useEffect } from 'react'
+import { useNavigate, useParams } from 'react-router-dom'
+import { ChapterTypeGraphQL } from 'types/chapter'
+import { ProjectTypeGraphql } from 'types/project'
+import { SnapshotDetailsProps } from 'types/snapshot'
+import { level } from 'utils/data'
+import { formatDate } from 'utils/dateFormatter'
+import { getFilteredIconsGraphql, handleSocialUrls } from 'utils/utility'
+import { ErrorDisplay } from 'wrappers/ErrorWrapper'
+import FontAwesomeIconWrapper from 'wrappers/FontAwesomeIconWrapper'
+import Card from 'components/Card'
+import ChapterMap from 'components/ChapterMap'
+import LoadingSpinner from 'components/LoadingSpinner'
+import MetadataManager from 'components/MetadataManager'
+
+const SnapshotDetailsPage: React.FC = () => {
+ const { id: snapshotKey } = useParams()
+ const [snapshot, setSnapshot] = useState(null)
+ const [isLoading, setIsLoading] = useState(true)
+ const navigate = useNavigate()
+
+ const { data: graphQLData, error: graphQLRequestError } = useQuery(GET_SNAPSHOT_DETAILS, {
+ variables: { key: snapshotKey },
+ })
+
+ useEffect(() => {
+ if (graphQLData) {
+ setSnapshot(graphQLData.snapshot)
+ setIsLoading(false)
+ }
+ if (graphQLRequestError) {
+ toast({
+ description: 'Unable to complete the requested operation.',
+ title: 'GraphQL Request Failed',
+ variant: 'destructive',
+ })
+ setIsLoading(false)
+ }
+ }, [graphQLData, graphQLRequestError, snapshotKey])
+
+ const renderProjectCard = (project: ProjectTypeGraphql) => {
+ const params: string[] = ['forksCount', 'starsCount', 'contributorsCount']
+ const filteredIcons = getFilteredIconsGraphql(project, params)
+
+ const handleButtonClick = () => {
+ navigate(`/projects/${project.key}`)
+ }
+
+ const SubmitButton = {
+ label: 'View Details',
+ icon: ,
+ onclick: handleButtonClick,
+ }
+
+ return (
+
+ )
+ }
+
+ const renderChapterCard = (chapter: ChapterTypeGraphQL) => {
+ const params: string[] = ['updatedAt']
+ const filteredIcons = getFilteredIconsGraphql(chapter, params)
+ const formattedUrls = handleSocialUrls(chapter.relatedUrls)
+
+ const handleButtonClick = () => {
+ navigate(`/chapters/${chapter.key}`)
+ }
+
+ const SubmitButton = {
+ label: 'View Details',
+ icon: ,
+ onclick: handleButtonClick,
+ }
+
+ return (
+
+ )
+ }
+
+ if (isLoading)
+ return (
+
+
+
+ )
+
+ if (!isLoading && snapshot == null) {
+ return (
+
+ )
+ }
+
+ return (
+
+
+
+
+
+
+ {snapshot.title}
+
+
+
+
+
+ {formatDate(snapshot.startAt)} - {formatDate(snapshot.endAt)}
+
+
+
+
+
+
+
+ {snapshot.newChapters && snapshot.newChapters.length > 0 && (
+
+
+ New Chapters
+
+
+
+
+
+ {snapshot.newChapters.filter((chapter) => chapter.isActive).map(renderChapterCard)}
+
+
+ )}
+
+ {snapshot.newProjects && snapshot.newProjects.length > 0 && (
+
+
+ New Projects
+
+
+ {snapshot.newProjects.filter((project) => project.isActive).map(renderProjectCard)}
+
+
+ )}
+
+ {snapshot.newReleases && snapshot.newReleases.length > 0 && (
+
+
New Releases
+
+ {snapshot.newReleases.map((release, index) => (
+
+
+
+
+
+ {release.projectName}
+
+
+ {release.tagName}
+
+
+
+
+ Released: {formatDate(release.publishedAt)}
+
+
+
+ ))}
+
+
+ )}
+
+
+ )
+}
+
+export default SnapshotDetailsPage
diff --git a/frontend/src/pages/index.ts b/frontend/src/pages/index.ts
index a49689210..9ba05d871 100644
--- a/frontend/src/pages/index.ts
+++ b/frontend/src/pages/index.ts
@@ -11,18 +11,20 @@ import Home from './Home'
import ProjectDetailsPage from './ProjectDetails'
import ProjectsPage from './Projects'
import RepositoryDetailsPage from './RepositoryDetails'
+import SnapshotDetailsPage from './SnapshotDetails'
import UserDetailsPage from './UserDetails'
import UsersPage from './Users'
export {
- Home,
- ProjectsPage,
- CommitteesPage,
+ ChapterDetailsPage,
ChaptersPage,
- ContributePage,
CommitteeDetailsPage,
- ChapterDetailsPage,
+ CommitteesPage,
+ ContributePage,
+ Home,
ProjectDetailsPage,
+ ProjectsPage,
RepositoryDetailsPage,
- UsersPage,
+ SnapshotDetailsPage,
UserDetailsPage,
+ UsersPage,
}
diff --git a/frontend/src/types/snapshot.ts b/frontend/src/types/snapshot.ts
new file mode 100644
index 000000000..57fcd4a86
--- /dev/null
+++ b/frontend/src/types/snapshot.ts
@@ -0,0 +1,19 @@
+import { ChapterTypeGraphQL } from 'types/chapter'
+import { ProjectTypeGraphql } from 'types/project'
+
+export interface ReleaseType {
+ name: string
+ publishedAt: string
+ tagName: string
+ projectName: string
+}
+
+export interface SnapshotDetailsProps {
+ endAt: string
+ key: string
+ startAt: string
+ title: string
+ newReleases: ReleaseType[]
+ newProjects: ProjectTypeGraphql[]
+ newChapters: ChapterTypeGraphQL[]
+}
diff --git a/frontend/src/utils/data.ts b/frontend/src/utils/data.ts
index 7dc195657..74f848deb 100644
--- a/frontend/src/utils/data.ts
+++ b/frontend/src/utils/data.ts
@@ -84,6 +84,26 @@ export const Icons = {
label: 'Comments count',
icon: 'fa-regular fa-comment',
},
+ starsCount: {
+ label: 'GitHub stars',
+ icon: 'fa-regular fa-star',
+ },
+ forksCount: {
+ label: 'GitHub forks',
+ icon: 'fa-solid fa-code-fork',
+ },
+ contributorsCount: {
+ label: 'GitHub contributors',
+ icon: 'fa-regular fa-user',
+ },
+ createdAt: {
+ label: 'Creation date',
+ icon: 'fa-regular fa-clock',
+ },
+ commentsCount: {
+ label: 'Comments count',
+ icon: 'fa-regular fa-comment',
+ },
} as const
export type IconKeys = keyof typeof Icons
diff --git a/frontend/src/utils/utility.ts b/frontend/src/utils/utility.ts
index 7ddbb8868..7aa5c19c5 100644
--- a/frontend/src/utils/utility.ts
+++ b/frontend/src/utils/utility.ts
@@ -2,11 +2,12 @@ import { type ClassValue, clsx } from 'clsx'
import dayjs from 'dayjs'
import relativeTime from 'dayjs/plugin/relativeTime'
import { twMerge } from 'tailwind-merge'
+import { ChapterTypeGraphQL } from 'types/chapter'
import { CommitteeTypeAlgolia } from 'types/committee'
import { IconType } from 'types/icon'
import { IssueType } from 'types/issue'
-import { ProjectTypeAlgolia } from 'types/project'
+import { ProjectTypeAlgolia, ProjectTypeGraphql } from 'types/project'
import { IconKeys, Icons, urlMappings } from 'utils/data'
dayjs.extend(relativeTime)
@@ -32,6 +33,24 @@ export const getFilteredIcons = (project: projectType, params: string[]): IconTy
return filteredIcons
}
+export const getFilteredIconsGraphql = (
+ project: ProjectTypeGraphql | ChapterTypeGraphQL,
+ params: string[]
+): IconType => {
+ const filteredIcons = params.reduce((acc: IconType, key) => {
+ if (Icons[key as IconKeys] && project[key as keyof typeof project] !== undefined) {
+ if (key === 'createdAt') {
+ acc[key] = dayjs.unix(project[key as keyof projectType] as number).fromNow()
+ } else {
+ acc[key] = project[key as keyof typeof project] as number
+ }
+ }
+ return acc
+ }, {})
+
+ return filteredIcons
+}
+
export const handleSocialUrls = (related_urls: string[]) => {
return related_urls.map((url) => {
const match = urlMappings.find((mapping) => url.includes(mapping.key))