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 ( - + {`${displayName}'s @@ -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.name} +
+
+
+ + {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))