diff --git a/backend/apps/core/utils/index.py b/backend/apps/core/utils/index.py index 7e7d799ae6..40637c91f4 100644 --- a/backend/apps/core/utils/index.py +++ b/backend/apps/core/utils/index.py @@ -196,6 +196,7 @@ def get_params_for_index(index_name: str) -> dict: case "users": params["attributesToRetrieve"] = [ "idx_avatar_url", + "idx_badge_count", "idx_bio", "idx_company", "idx_created_at", diff --git a/backend/apps/github/api/internal/nodes/user.py b/backend/apps/github/api/internal/nodes/user.py index 1d99857d97..f9fbb7198e 100644 --- a/backend/apps/github/api/internal/nodes/user.py +++ b/backend/apps/github/api/internal/nodes/user.py @@ -28,18 +28,27 @@ class UserNode: """GitHub user node.""" + @strawberry.field + def badge_count(self) -> int: + """Resolve badge count.""" + return self.user_badges.filter(is_active=True).count() + @strawberry.field def badges(self) -> list[BadgeNode]: """Return user badges.""" user_badges = ( - self.user_badges.select_related("badge") - .filter(is_active=True) + self.user_badges.filter( + is_active=True, + ) + .select_related( + "badge", + ) .order_by( "badge__weight", "badge__name", ) ) - return [ub.badge for ub in user_badges] + return [user_badge.badge for user_badge in user_badges] @strawberry.field def created_at(self) -> float: diff --git a/backend/apps/github/index/registry/user.py b/backend/apps/github/index/registry/user.py index e66b1a14c1..e2a2c85dca 100644 --- a/backend/apps/github/index/registry/user.py +++ b/backend/apps/github/index/registry/user.py @@ -14,6 +14,7 @@ class UserIndex(IndexBase): fields = ( "idx_avatar_url", + "idx_badge_count", "idx_bio", "idx_company", "idx_contributions", diff --git a/backend/apps/github/index/search/user.py b/backend/apps/github/index/search/user.py index c85e4b6543..4cc99ec11e 100644 --- a/backend/apps/github/index/search/user.py +++ b/backend/apps/github/index/search/user.py @@ -32,6 +32,7 @@ def get_users( "attributesToRetrieve": attributes or [ "idx_avatar_url", + "idx_badge_count", "idx_bio", "idx_company", "idx_contributions", diff --git a/backend/apps/github/models/mixins/user.py b/backend/apps/github/models/mixins/user.py index b7b09c821d..bdbb2c2f19 100644 --- a/backend/apps/github/models/mixins/user.py +++ b/backend/apps/github/models/mixins/user.py @@ -24,6 +24,11 @@ def idx_avatar_url(self) -> str: """Return avatar URL for indexing.""" return self.avatar_url + @property + def idx_badge_count(self) -> int: + """Return badge count for indexing.""" + return self.user_badges.filter(is_active=True).count() + @property def idx_bio(self) -> str: """Return bio for indexing.""" diff --git a/backend/apps/nest/migrations/0007_alter_badge_css_class.py b/backend/apps/nest/migrations/0007_alter_badge_css_class.py new file mode 100644 index 0000000000..fdb7ad07d7 --- /dev/null +++ b/backend/apps/nest/migrations/0007_alter_badge_css_class.py @@ -0,0 +1,29 @@ +# Generated by Django 5.2.6 on 2025-10-09 04:06 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("nest", "0006_delete_apikey"), + ] + + operations = [ + migrations.AlterField( + model_name="badge", + name="css_class", + field=models.CharField( + choices=[ + ("award", "Award"), + ("medal", "Medal"), + ("ribbon", "Ribbon"), + ("star", "Star"), + ("certificate", "Certificate"), + ("bug_slash", "Bug Slash"), + ], + default="medal", + max_length=255, + verbose_name="CSS Class", + ), + ), + ] diff --git a/backend/apps/nest/models/badge.py b/backend/apps/nest/models/badge.py index 1378dda041..112b3d1575 100644 --- a/backend/apps/nest/models/badge.py +++ b/backend/apps/nest/models/badge.py @@ -10,6 +10,14 @@ class Badge(BulkSaveModel, TimestampedModel): """Represents a user badge for roles or achievements.""" + class BadgeCssClass(models.TextChoices): + AWARD = "award", "Award" + BUG_SLASH = "bug_slash", "Bug Slash" + CERTIFICATE = "certificate", "Certificate" + MEDAL = "medal", "Medal" + RIBBON = "ribbon", "Ribbon" + STAR = "star", "Star" + class Meta: db_table = "nest_badges" ordering = ["weight", "name"] @@ -18,7 +26,8 @@ class Meta: css_class = models.CharField( verbose_name="CSS Class", max_length=255, - default="", + choices=BadgeCssClass.choices, + default=BadgeCssClass.MEDAL, ) description = models.CharField( verbose_name="Description", diff --git a/backend/tests/apps/core/utils/match_test.py b/backend/tests/apps/core/utils/match_test.py index 39b1a667e7..b8c2efb751 100644 --- a/backend/tests/apps/core/utils/match_test.py +++ b/backend/tests/apps/core/utils/match_test.py @@ -114,6 +114,7 @@ def test_get_params_for_users(self): "typoTolerance": "min", "attributesToRetrieve": [ "idx_avatar_url", + "idx_badge_count", "idx_bio", "idx_company", "idx_created_at", diff --git a/backend/tests/apps/github/api/internal/nodes/user_test.py b/backend/tests/apps/github/api/internal/nodes/user_test.py index f83e831433..8da4ba1943 100644 --- a/backend/tests/apps/github/api/internal/nodes/user_test.py +++ b/backend/tests/apps/github/api/internal/nodes/user_test.py @@ -5,6 +5,7 @@ import pytest from apps.github.api.internal.nodes.user import UserNode +from apps.nest.api.internal.nodes.badge import BadgeNode class TestUserNode: @@ -19,6 +20,7 @@ def test_meta_configuration(self): field_names = {field.name for field in UserNode.__strawberry_definition__.fields} expected_field_names = { "avatar_url", + "badge_count", "badges", "bio", "company", @@ -81,42 +83,110 @@ def test_url_field(self): result = UserNode.url(mock_user) assert result == "https://github.com/testuser" - def test_badges_resolver_behavior(self): - """Unit test verifies the badges method returns badges sorted by weight.""" - badge_high = Mock() - badge_high.weight = 1 - badge_high.name = "High Priority Badge" - - badge_medium = Mock() - badge_medium.weight = 5 - badge_medium.name = "Medium Priority Badge" + def test_badge_count_field(self): + """Test badge_count field resolution.""" + mock_user = Mock() + mock_badges_queryset = Mock() + mock_badges_queryset.filter.return_value.count.return_value = 3 + mock_user.user_badges = mock_badges_queryset - badge_low = Mock() - badge_low.weight = 10 - badge_low.name = "Low Priority Badge" + result = UserNode.badge_count(mock_user) + assert result == 3 + mock_badges_queryset.filter.assert_called_once_with(is_active=True) + mock_badges_queryset.filter.return_value.count.assert_called_once() - user_badge_high = Mock() - user_badge_high.badge = badge_high + def test_badges_field_empty(self): + """Test badges field resolution with no badges.""" + mock_user = Mock() + mock_badges_queryset = Mock() + mock_filter = mock_badges_queryset.filter.return_value + mock_select_related = mock_filter.select_related.return_value + mock_select_related.order_by.return_value = [] + mock_user.user_badges = mock_badges_queryset - user_badge_medium = Mock() - user_badge_medium.badge = badge_medium + result = UserNode.badges(mock_user) + assert result == [] + mock_badges_queryset.filter.assert_called_once_with(is_active=True) + mock_filter.select_related.assert_called_once_with("badge") + mock_select_related.order_by.assert_called_once_with("badge__weight", "badge__name") - user_badge_low = Mock() - user_badge_low.badge = badge_low + def test_badges_field_single_badge(self): + """Test badges field resolution with single badge.""" + mock_user = Mock() + mock_badge = Mock(spec=BadgeNode) + mock_user_badge = Mock() + mock_user_badge.badge = mock_badge - sorted_user_badges = [user_badge_high, user_badge_medium, user_badge_low] + mock_badges_queryset = Mock() + mock_filter = mock_badges_queryset.filter.return_value + mock_select_related = mock_filter.select_related.return_value + mock_select_related.order_by.return_value = [mock_user_badge] + mock_user.user_badges = mock_badges_queryset + result = UserNode.badges(mock_user) + assert result == [mock_badge] + mock_badges_queryset.filter.assert_called_once_with(is_active=True) + mock_filter.select_related.assert_called_once_with("badge") + mock_select_related.order_by.assert_called_once_with("badge__weight", "badge__name") + + def test_badges_field_sorted_by_weight_and_name(self): + """Test badges field resolution with multiple badges sorted by weight and name.""" + # Create mock badges with different weights and names + mock_badge_high_weight = Mock(spec=BadgeNode) + mock_badge_high_weight.weight = 100 + mock_badge_high_weight.name = "High Weight Badge" + + mock_badge_medium_weight_a = Mock(spec=BadgeNode) + mock_badge_medium_weight_a.weight = 50 + mock_badge_medium_weight_a.name = "Medium Weight A" + + mock_badge_medium_weight_b = Mock(spec=BadgeNode) + mock_badge_medium_weight_b.weight = 50 + mock_badge_medium_weight_b.name = "Medium Weight B" + + mock_badge_low_weight = Mock(spec=BadgeNode) + mock_badge_low_weight.weight = 10 + mock_badge_low_weight.name = "Low Weight Badge" + + # Create mock user badges + mock_user_badge_high = Mock() + mock_user_badge_high.badge = mock_badge_high_weight + + mock_user_badge_medium_a = Mock() + mock_user_badge_medium_a.badge = mock_badge_medium_weight_a + + mock_user_badge_medium_b = Mock() + mock_user_badge_medium_b.badge = mock_badge_medium_weight_b + + mock_user_badge_low = Mock() + mock_user_badge_low.badge = mock_badge_low_weight + + # Set up the mock queryset to return badges in the expected sorted order + # (lowest weight first, then by name for same weight) + mock_badges_queryset = Mock() + mock_filter = mock_badges_queryset.filter.return_value + mock_select_related = mock_filter.select_related.return_value + mock_select_related.order_by.return_value = [ + mock_user_badge_low, # weight 10 + mock_user_badge_medium_a, # weight 50, name "Medium Weight A" + mock_user_badge_medium_b, # weight 50, name "Medium Weight B" + mock_user_badge_high, # weight 100 + ] mock_user = Mock() - mock_queryset = Mock() - mock_queryset.filter.return_value.order_by.return_value = sorted_user_badges - mock_user.user_badges.select_related.return_value = mock_queryset + mock_user.user_badges = mock_badges_queryset result = UserNode.badges(mock_user) - mock_user.user_badges.select_related.assert_called_once_with("badge") - mock_queryset.filter.assert_called_once_with(is_active=True) - mock_queryset.filter.return_value.order_by.assert_called_once_with( - "badge__weight", "badge__name" - ) - - assert result == [ub.badge for ub in sorted_user_badges] + # Verify the badges are returned in the correct order + expected_badges = [ + mock_badge_low_weight, + mock_badge_medium_weight_a, + mock_badge_medium_weight_b, + mock_badge_high_weight, + ] + assert result == expected_badges + + # Verify the queryset was called with correct ordering + mock_badges_queryset.filter.assert_called_once_with(is_active=True) + mock_filter.select_related.assert_called_once_with("badge") + mock_select_related.order_by.assert_called_once_with("badge__weight", "badge__name") diff --git a/frontend/__tests__/unit/components/Badges.test.tsx b/frontend/__tests__/unit/components/Badges.test.tsx new file mode 100644 index 0000000000..bcec77c5d0 --- /dev/null +++ b/frontend/__tests__/unit/components/Badges.test.tsx @@ -0,0 +1,121 @@ +import { render, screen } from '@testing-library/react' +import React from 'react' +import Badges from 'components/Badges' + +jest.mock('wrappers/FontAwesomeIconWrapper', () => { + const RealWrapper = jest.requireActual('wrappers/FontAwesomeIconWrapper').default + + const getName = (icon) => { + if (!icon) return 'medal' + if (typeof icon === 'string') { + const m = icon.match(/fa-([a-z0-9-]+)$/i) + if (m) return m[1] + const last = icon.trim().split(/\s+/).pop() || '' + return last.replace(/^fa-/, '') || 'medal' + } + if (Array.isArray(icon) && icon.length >= 2) return String(icon[1]) + if (icon && typeof icon === 'object') return icon.iconName || String(icon[1] ?? 'medal') + return 'medal' + } + + return function MockFontAwesomeIconWrapper(props) { + const name = getName(props.icon) + return ( +
+ +
+ ) + } +}) + +jest.mock('@heroui/tooltip', () => ({ + Tooltip: ({ + children, + content, + isDisabled, + }: { + children: React.ReactNode + content: string + isDisabled?: boolean + }) => { + if (isDisabled) { + return <>{children} + } + return ( +
+ {children} +
+ ) + }, +})) + +//only for confirming the badges are working properly +describe('Badges Component', () => { + const defaultProps = { + name: 'Test Badge', + cssClass: 'medal', + } + + it('renders valid icon with tooltip', () => { + render() + + const icon = screen.getByTestId('badge-icon') + expect(icon).toBeInTheDocument() + expect(icon).toHaveAttribute('data-icon', 'medal') + expect(screen.getByTestId('tooltip')).toHaveAttribute('data-content', 'Test Badge') + }) + + it('renders fallback fa-medal for invalid cssClass', () => { + render() + + const icon = screen.getByTestId('badge-icon') + expect(icon).toBeInTheDocument() + expect(icon).toHaveAttribute('data-icon', 'medal') + }) + + it('renders fallback medal for unrecognized icon', () => { + render() + + const icon = screen.getByTestId('badge-icon') + expect(icon).toBeInTheDocument() + expect(icon).toHaveAttribute('data-icon', 'medal') + }) + + it('hides tooltip when showTooltip is false', () => { + render() + + const icon = screen.getByTestId('badge-icon') + expect(icon).toBeInTheDocument() + expect(icon).toHaveAttribute('data-icon', 'medal') + expect(screen.queryByTestId('tooltip')).not.toBeInTheDocument() + }) + + describe('Backend enum icons', () => { + const backendIcons = [ + { cssClass: 'award', expectedIcon: 'award' }, + { cssClass: 'medal', expectedIcon: 'medal' }, + { cssClass: 'ribbon', expectedIcon: 'ribbon' }, + { cssClass: 'star', expectedIcon: 'star' }, + { cssClass: 'certificate', expectedIcon: 'certificate' }, + { cssClass: 'bug_slash', expectedIcon: 'bug' }, // Backend snake_case input + ] + + backendIcons.forEach(({ cssClass, expectedIcon }) => { + it(`renders ${cssClass} icon correctly (transforms snake_case to camelCase)`, () => { + render() + + const icon = screen.getByTestId('badge-icon') + expect(icon).toBeInTheDocument() + expect(icon).toHaveAttribute('data-icon', expectedIcon) + }) + }) + + it('handles camelCase input directly', () => { + render() + + const icon = screen.getByTestId('badge-icon') + expect(icon).toBeInTheDocument() + expect(icon).toHaveAttribute('data-icon', 'bug') + }) + }) +}) diff --git a/frontend/__tests__/unit/components/UserCard.test.tsx b/frontend/__tests__/unit/components/UserCard.test.tsx index a75505af28..7a57af134a 100644 --- a/frontend/__tests__/unit/components/UserCard.test.tsx +++ b/frontend/__tests__/unit/components/UserCard.test.tsx @@ -90,6 +90,7 @@ describe('UserCard', () => { followersCount: 0, location: '', repositoriesCount: 0, + badgeCount: 0, } beforeEach(() => { @@ -120,6 +121,7 @@ describe('UserCard', () => { followersCount: 1500, location: 'San Francisco, CA', repositoriesCount: 25, + badgeCount: 5, button: { label: 'View Profile', onclick: mockButtonClick, @@ -379,4 +381,41 @@ describe('UserCard', () => { expect(screen.getByText('5.7k')).toBeInTheDocument() }) }) + + describe('Badge Count Display', () => { + it('renders badge count when greater than 0', () => { + render() + + expect(screen.getByTestId('icon-medal')).toBeInTheDocument() + expect(screen.getByText('5')).toBeInTheDocument() + }) + + it('does not render badge count when 0', () => { + render() + + expect(screen.queryByTestId('icon-medal')).not.toBeInTheDocument() + }) + + it('renders all three metrics when all are greater than 0', () => { + render( + + ) + + expect(screen.getByTestId('icon-users')).toBeInTheDocument() + expect(screen.getByTestId('icon-folder-open')).toBeInTheDocument() + expect(screen.getByTestId('icon-medal')).toBeInTheDocument() + }) + + it('formats badge count with millify for large numbers', () => { + render() + + expect(screen.getByText('1.5k')).toBeInTheDocument() + }) + + it('handles negative badge count', () => { + render() + + expect(screen.queryByTestId('icon-medal')).not.toBeInTheDocument() + }) + }) }) diff --git a/frontend/__tests__/unit/data/mockBadgeData.ts b/frontend/__tests__/unit/data/mockBadgeData.ts new file mode 100644 index 0000000000..afde137fd2 --- /dev/null +++ b/frontend/__tests__/unit/data/mockBadgeData.ts @@ -0,0 +1,63 @@ +import type { Badge } from 'types/badge' + +export const mockBadgeData: Badge[] = [ + { + id: '1', + name: 'Contributor', + cssClass: 'fa-medal', + description: 'Active contributor to OWASP projects', + weight: 1, + }, + { + id: '2', + name: 'Security Expert', + cssClass: 'fa-shield-alt', + description: 'Security expertise demonstrated', + weight: 2, + }, + { + id: '3', + name: 'Code Reviewer', + cssClass: 'fa-code', + description: 'Regular code reviewer', + weight: 1, + }, + { + id: '4', + name: 'Mentor', + cssClass: 'fa-user-graduate', + description: 'Mentors other contributors', + weight: 3, + }, + { + id: '5', + name: 'Project Lead', + cssClass: 'fa-crown', + description: 'Leads OWASP projects', + weight: 4, + }, +] + +export const mockUserBadgeQueryResponse = { + user: { + id: '1', + login: 'testuser', + name: 'Test User', + badges: mockBadgeData, + badgeCount: 5, + }, +} + +export const mockUserWithoutBadgeQueryResponse = { + user: { + id: '2', + login: 'testuser2', + name: 'Test User 2', + badges: [], + badgeCount: 0, + }, +} + +export const mockBadgeQueryResponse = { + badges: mockBadgeData, +} diff --git a/frontend/__tests__/unit/data/mockUserDetails.ts b/frontend/__tests__/unit/data/mockUserDetails.ts index a082f47170..5082584541 100644 --- a/frontend/__tests__/unit/data/mockUserDetails.ts +++ b/frontend/__tests__/unit/data/mockUserDetails.ts @@ -13,6 +13,23 @@ export const mockUserDetailsData = { publicRepositoriesCount: 3, createdAt: 1723002473, contributionsCount: 100, + badges: [ + { + id: '1', + name: 'Contributor', + cssClass: 'fa-medal', + description: 'Active contributor to OWASP projects', + weight: 1, + }, + { + id: '2', + name: 'Security Expert', + cssClass: 'fa-shield-alt', + description: 'Security expertise demonstrated', + weight: 2, + }, + ], + badgeCount: 2, }, recentIssues: [ { diff --git a/frontend/__tests__/unit/pages/UserDetails.test.tsx b/frontend/__tests__/unit/pages/UserDetails.test.tsx index 1f8eb46038..89498478f5 100644 --- a/frontend/__tests__/unit/pages/UserDetails.test.tsx +++ b/frontend/__tests__/unit/pages/UserDetails.test.tsx @@ -15,9 +15,46 @@ jest.mock('@apollo/client/react', () => ({ // Mock FontAwesome jest.mock('@fortawesome/react-fontawesome', () => ({ - FontAwesomeIcon: () => , + FontAwesomeIcon: ({ + icon, + className, + ...props + }: { + icon: string[] | { iconName: string } + className?: string + [key: string]: unknown + }) => { + const iconName = Array.isArray(icon) ? icon[1] : icon.iconName + return + }, })) +// Mock Badges component +jest.mock('components/Badges', () => { + const MockBadges = ({ + name, + cssClass, + showTooltip, + }: { + name: string + cssClass: string + showTooltip?: boolean + }) => ( +
+ +
+ ) + MockBadges.displayName = 'MockBadges' + return { + __esModule: true, + default: MockBadges, + } +}) + const mockRouter = { push: jest.fn(), } @@ -29,7 +66,7 @@ const mockError = { jest.mock('next/navigation', () => ({ ...jest.requireActual('next/navigation'), useRouter: jest.fn(() => mockRouter), - useParams: () => ({ userKey: 'test-user' }), + useParams: () => ({ memberKey: 'test-user' }), })) // Mock GitHub heatmap utilities @@ -498,4 +535,275 @@ describe('UserDetailsPage', () => { expect(screen.queryByText(`Want to become a sponsor?`)).toBeNull() }) }) + + describe('Badge Display Tests', () => { + test('renders badges section when user has badges', async () => { + ;(useQuery as unknown as jest.Mock).mockReturnValue({ + data: mockUserDetailsData, + loading: false, + error: null, + }) + + render() + await waitFor(() => { + expect(screen.getByTestId('badge-contributor')).toBeInTheDocument() + expect(screen.getByTestId('badge-security-expert')).toBeInTheDocument() + }) + }) + + test('renders badges with correct props', async () => { + ;(useQuery as unknown as jest.Mock).mockReturnValue({ + data: mockUserDetailsData, + loading: false, + error: null, + }) + + render() + await waitFor(() => { + const contributorBadge = screen.getByTestId('badge-contributor') + expect(contributorBadge).toHaveAttribute('data-css-class', 'fa-medal') + expect(contributorBadge).toHaveAttribute('data-show-tooltip', 'true') + + const securityBadge = screen.getByTestId('badge-security-expert') + expect(securityBadge).toHaveAttribute('data-css-class', 'fa-shield-alt') + expect(securityBadge).toHaveAttribute('data-show-tooltip', 'true') + }) + }) + + test('does not render badges section when user has no badges', async () => { + const dataWithoutBadges = { + ...mockUserDetailsData, + user: { + ...mockUserDetailsData.user, + badges: [], + badgeCount: 0, + }, + } + + ;(useQuery as unknown as jest.Mock).mockReturnValue({ + data: dataWithoutBadges, + loading: false, + error: null, + }) + + render() + await waitFor(() => { + expect(screen.queryByTestId(/^badge-/)).not.toBeInTheDocument() + }) + }) + + test('does not render badges section when badges is undefined', async () => { + const dataWithoutBadges = { + ...mockUserDetailsData, + user: { + ...mockUserDetailsData.user, + badges: undefined, + badgeCount: 0, + }, + } + + ;(useQuery as unknown as jest.Mock).mockReturnValue({ + data: dataWithoutBadges, + loading: false, + error: null, + }) + + render() + await waitFor(() => { + expect(screen.queryByTestId(/^badge-/)).not.toBeInTheDocument() + }) + }) + + test('renders badges with fallback cssClass when not provided', async () => { + const dataWithIncompleteBadges = { + ...mockUserDetailsData, + user: { + ...mockUserDetailsData.user, + badges: [ + { + id: '1', + name: 'Test Badge', + cssClass: undefined, + description: 'Test description', + weight: 1, + }, + ], + }, + } + + ;(useQuery as unknown as jest.Mock).mockReturnValue({ + data: dataWithIncompleteBadges, + loading: false, + error: null, + }) + + render() + await waitFor(() => { + const badge = screen.getByTestId('badge-test-badge') + expect(badge).toHaveAttribute('data-css-class', 'fa-medal') + }) + }) + + test('renders badges with empty cssClass fallback', async () => { + const dataWithEmptyCssClass = { + ...mockUserDetailsData, + user: { + ...mockUserDetailsData.user, + badges: [ + { + id: '1', + name: 'Test Badge', + cssClass: '', + description: 'Test description', + weight: 1, + }, + ], + }, + } + + ;(useQuery as unknown as jest.Mock).mockReturnValue({ + data: dataWithEmptyCssClass, + loading: false, + error: null, + }) + + render() + await waitFor(() => { + const badge = screen.getByTestId('badge-test-badge') + expect(badge).toHaveAttribute('data-css-class', 'fa-medal') + }) + }) + + test('handles badges with special characters in names', async () => { + const dataWithSpecialBadges = { + ...mockUserDetailsData, + user: { + ...mockUserDetailsData.user, + badges: [ + { + id: '1', + name: 'Badge & More!', + cssClass: 'fa-star', + description: 'Special badge', + weight: 1, + }, + ], + }, + } + ;(useQuery as unknown as jest.Mock).mockReturnValue({ + data: dataWithSpecialBadges, + loading: false, + error: null, + }) + + render() + await waitFor(() => { + expect(screen.getByTestId('badge-badge-&-more!')).toBeInTheDocument() + }) + }) + + test('handles badges with long names', async () => { + const dataWithLongNameBadge = { + ...mockUserDetailsData, + user: { + ...mockUserDetailsData.user, + badges: [ + { + id: '1', + name: 'Very Long Badge Name That Exceeds Normal Length', + cssClass: 'fa-trophy', + description: 'Long name badge', + weight: 1, + }, + ], + }, + } + + ;(useQuery as unknown as jest.Mock).mockReturnValue({ + data: dataWithLongNameBadge, + loading: false, + error: null, + }) + + render() + await waitFor(() => { + expect( + screen.getByTestId('badge-very-long-badge-name-that-exceeds-normal-length') + ).toBeInTheDocument() + }) + }) + + test('renders badges in correct order as returned by backend (weight ASC then name ASC)', async () => { + // Backend returns badges sorted by weight ASC, then name ASC + // This test verifies the frontend preserves the backend ordering + const dataWithOrderedBadges = { + ...mockUserDetailsData, + user: { + ...mockUserDetailsData.user, + badges: [ + // Backend returns badges in this order: weight ASC, then name ASC + { + id: '3', + name: 'Alpha Badge', + cssClass: 'fa-star', + description: 'Alpha badge with weight 1', + weight: 1, + }, + { + id: '4', + name: 'Beta Badge', + cssClass: 'fa-trophy', + description: 'Beta badge with weight 1', + weight: 1, + }, + { + id: '1', + name: 'Contributor', + cssClass: 'fa-medal', + description: 'Active contributor', + weight: 1, + }, + { + id: '2', + name: 'Security Expert', + cssClass: 'fa-shield-alt', + description: 'Security expertise', + weight: 2, + }, + { + id: '5', + name: 'Top Contributor', + cssClass: 'fa-crown', + description: 'Highest weight badge', + weight: 3, + }, + ], + badgeCount: 5, + }, + } + + ;(useQuery as unknown as jest.Mock).mockReturnValue({ + data: dataWithOrderedBadges, + loading: false, + error: null, + }) + + render() + await waitFor(() => { + const badgeElements = screen.getAllByTestId(/^badge-/) + const badgeTestIds = badgeElements.map((element) => element.getAttribute('data-testid')) + + // Expected order matches backend contract: weight ASC (1, 1, 1, 2, 3), then name ASC for equal weights + const expectedOrder = [ + 'badge-alpha-badge', // weight 1, name ASC + 'badge-beta-badge', // weight 1, name ASC + 'badge-contributor', // weight 1, name ASC + 'badge-security-expert', // weight 2 + 'badge-top-contributor', // weight 3 + ] + + expect(badgeTestIds).toEqual(expectedOrder) + }) + }) + }) }) diff --git a/frontend/package.json b/frontend/package.json index e544dd8811..cce6b0570c 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -77,7 +77,7 @@ "@graphql-codegen/typescript-operations": "^5.0.2", "@lhci/cli": "^0.15.1", "@playwright/test": "^1.56.0", - "@swc/core": "^1.13.20", + "@swc/core": "1.13.19", "@swc/jest": "^0.2.39", "@tailwindcss/postcss": "^4.1.14", "@testing-library/jest-dom": "^6.9.1", diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index bca3b89d68..b03b459a0d 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -64,7 +64,7 @@ importers: version: 15.5.4(next@15.5.4(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.56.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react@19.2.0) '@sentry/nextjs': specifier: ^10.19.0 - version: 10.19.0(@opentelemetry/context-async-hooks@2.1.0(@opentelemetry/api@1.9.0))(@opentelemetry/core@2.1.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.1.0(@opentelemetry/api@1.9.0))(next@15.5.4(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.56.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react@19.2.0)(webpack@5.101.3(@swc/core@1.13.20(@swc/helpers@0.5.17))) + version: 10.19.0(@opentelemetry/context-async-hooks@2.1.0(@opentelemetry/api@1.9.0))(@opentelemetry/core@2.1.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.1.0(@opentelemetry/api@1.9.0))(next@15.5.4(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.56.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react@19.2.0)(webpack@5.101.3(@swc/core@1.13.19(@swc/helpers@0.5.17))) '@testing-library/user-event': specifier: ^14.6.1 version: 14.6.1(@testing-library/dom@10.4.1) @@ -181,11 +181,11 @@ importers: specifier: ^1.56.0 version: 1.56.0 '@swc/core': - specifier: ^1.13.20 - version: 1.13.20(@swc/helpers@0.5.17) + specifier: 1.13.19 + version: 1.13.19(@swc/helpers@0.5.17) '@swc/jest': specifier: ^0.2.39 - version: 0.2.39(@swc/core@1.13.20(@swc/helpers@0.5.17)) + version: 0.2.39(@swc/core@1.13.19(@swc/helpers@0.5.17)) '@tailwindcss/postcss': specifier: ^4.1.14 version: 4.1.14 @@ -239,7 +239,7 @@ importers: version: 1.1.2(eslint-plugin-import@2.32.0) eslint-plugin-jest: specifier: ^29.0.1 - version: 29.0.1(@typescript-eslint/eslint-plugin@8.46.0(@typescript-eslint/parser@8.46.0(eslint@9.37.0(jiti@2.6.1))(typescript@5.9.3))(eslint@9.37.0(jiti@2.6.1))(typescript@5.9.3))(eslint@9.37.0(jiti@2.6.1))(jest@30.2.0(@types/node@22.18.10)(ts-node@10.9.2(@swc/core@1.13.20(@swc/helpers@0.5.17))(@types/node@22.18.10)(typescript@5.9.3)))(typescript@5.9.3) + version: 29.0.1(@typescript-eslint/eslint-plugin@8.46.0(@typescript-eslint/parser@8.46.0(eslint@9.37.0(jiti@2.6.1))(typescript@5.9.3))(eslint@9.37.0(jiti@2.6.1))(typescript@5.9.3))(eslint@9.37.0(jiti@2.6.1))(jest@30.2.0(@types/node@22.18.10)(ts-node@10.9.2(@swc/core@1.13.19(@swc/helpers@0.5.17))(@types/node@22.18.10)(typescript@5.9.3)))(typescript@5.9.3) eslint-plugin-jsx-a11y: specifier: ^6.10.2 version: 6.10.2(eslint@9.37.0(jiti@2.6.1)) @@ -263,7 +263,7 @@ importers: version: 1.15.0 jest: specifier: ^30.2.0 - version: 30.2.0(@types/node@22.18.10)(ts-node@10.9.2(@swc/core@1.13.20(@swc/helpers@0.5.17))(@types/node@22.18.10)(typescript@5.9.3)) + version: 30.2.0(@types/node@22.18.10)(ts-node@10.9.2(@swc/core@1.13.19(@swc/helpers@0.5.17))(@types/node@22.18.10)(typescript@5.9.3)) jest-axe: specifier: ^10.0.0 version: 10.0.0 @@ -290,10 +290,10 @@ importers: version: 4.1.14 ts-jest: specifier: ^29.4.5 - version: 29.4.5(@babel/core@7.28.4)(@jest/transform@30.2.0)(@jest/types@30.2.0)(babel-jest@30.2.0(@babel/core@7.28.4))(jest-util@30.2.0)(jest@30.2.0(@types/node@22.18.10)(ts-node@10.9.2(@swc/core@1.13.20(@swc/helpers@0.5.17))(@types/node@22.18.10)(typescript@5.9.3)))(typescript@5.9.3) + version: 29.4.5(@babel/core@7.28.4)(@jest/transform@30.2.0)(@jest/types@30.2.0)(babel-jest@30.2.0(@babel/core@7.28.4))(jest-util@30.2.0)(jest@30.2.0(@types/node@22.18.10)(ts-node@10.9.2(@swc/core@1.13.19(@swc/helpers@0.5.17))(@types/node@22.18.10)(typescript@5.9.3)))(typescript@5.9.3) ts-node: specifier: ^10.9.2 - version: 10.9.2(@swc/core@1.13.20(@swc/helpers@0.5.17))(@types/node@22.18.10)(typescript@5.9.3) + version: 10.9.2(@swc/core@1.13.19(@swc/helpers@0.5.17))(@types/node@22.18.10)(typescript@5.9.3) typescript: specifier: ~5.9.3 version: 5.9.3 @@ -3174,68 +3174,68 @@ packages: peerDependencies: '@svgdotjs/svg.js': ^3.2.4 - '@swc/core-darwin-arm64@1.13.20': - resolution: {integrity: sha512-k/nqRwm6G3tw1BbCDxc3KmAMGsuDYA5Uh4MjYm23e+UziLyHz0z7W0zja3el+yGBIZXKlgSzWVFLsFDFzVqtgg==} + '@swc/core-darwin-arm64@1.13.19': + resolution: {integrity: sha512-NxDyte9tCJSJ8+R62WDtqwg8eI57lubD52sHyGOfezpJBOPr36bUSGGLyO3Vod9zTGlOu2CpkuzA/2iVw92u1g==} engines: {node: '>=10'} cpu: [arm64] os: [darwin] - '@swc/core-darwin-x64@1.13.20': - resolution: {integrity: sha512-7xr+ACdUMNyrN87oEF1GvJIZJBAhGolfQVB0EYP08JEy8VSh//FEwfdlUz8gweaZyjOl1nuPS6ncXlKgZuZU8A==} + '@swc/core-darwin-x64@1.13.19': + resolution: {integrity: sha512-+w5DYrJndSygFFRDcuPYmx5BljD6oYnAohZ15K1L6SfORHp/BTSIbgSFRKPoyhjuIkDiq3W0um8RoMTOBAcQjQ==} engines: {node: '>=10'} cpu: [x64] os: [darwin] - '@swc/core-linux-arm-gnueabihf@1.13.20': - resolution: {integrity: sha512-IaOLxU1U/oGV3lZ2T8tD5nB/5O60UFPqj5ZxYzDpCBVB73tDQDIxiDcro1X81nHbwJHjuHmbIrhoflS7LQN6+A==} + '@swc/core-linux-arm-gnueabihf@1.13.19': + resolution: {integrity: sha512-7LlfgpdwwYq2q7himNkAAFo4q6jysMLFNoBH6GRP7WL29NcSsl5mPMJjmYZymK+sYq/9MTVieDTQvChzYDsapw==} engines: {node: '>=10'} cpu: [arm] os: [linux] - '@swc/core-linux-arm64-gnu@1.13.20': - resolution: {integrity: sha512-Lg6FyotDydXGnNnlw+u7vCZzR2+fX3Q2HiULBTYl2dey3TvRyzAfEhdgMjUc4beRzf26U9rzMuTroJ6KMBCBjA==} + '@swc/core-linux-arm64-gnu@1.13.19': + resolution: {integrity: sha512-ml3I6Lm2marAQ3UC/TS9t/yILBh/eDSVHAdPpikp652xouWAVW1znUeV6bBSxe1sSZIenv+p55ubKAWq/u84sQ==} engines: {node: '>=10'} cpu: [arm64] os: [linux] - '@swc/core-linux-arm64-musl@1.13.20': - resolution: {integrity: sha512-d1SvxmFykS0Ep8nPbduV1UwCvFjZ3ESzFKQdTbkr72bge8AseILBI9TbLTmoeWndDaTesiiTKRD5Y1iAvF1wvA==} + '@swc/core-linux-arm64-musl@1.13.19': + resolution: {integrity: sha512-M/otFc3/rWWkbF6VgbOXVzUKVoE7MFcphTaStxJp4bwb7oP5slYlxMZN51Dk/OTOfvCDo9pTAFDKNyixbkXMDQ==} engines: {node: '>=10'} cpu: [arm64] os: [linux] - '@swc/core-linux-x64-gnu@1.13.20': - resolution: {integrity: sha512-Bwmng57EuMod58Q8GDJA8rmUgFl20taK8w8MqeeDMiCnZY2+rJrNERbIX3sXZbwsf/kCIELZ7q4ZXiwdyB4zoQ==} + '@swc/core-linux-x64-gnu@1.13.19': + resolution: {integrity: sha512-NoMUKaOJEdouU4tKF88ggdDHFiRRING+gYLxDqnTfm+sUXaizB5OGBRzvSVDYSXQb1SuUuChnXFPFzwTWbt3ZQ==} engines: {node: '>=10'} cpu: [x64] os: [linux] - '@swc/core-linux-x64-musl@1.13.20': - resolution: {integrity: sha512-osCm3VEKL/OIKInyhy75S5B+R+QGBdpR1B5vwTYqG/1RB4vFM3O5SDtRZabd6NV9Cxc9dcLztWyZjhs2qp63SQ==} + '@swc/core-linux-x64-musl@1.13.19': + resolution: {integrity: sha512-r6krlZwyu8SBaw24QuS1lau2I9q8M+eJV6ITz0rpb6P1Bx0elf9ii5Bhh8ddmIqXXH8kOGSjC/dwcdHbZqAhgw==} engines: {node: '>=10'} cpu: [x64] os: [linux] - '@swc/core-win32-arm64-msvc@1.13.20': - resolution: {integrity: sha512-svbQNirwEa6zwaAJPrEmQnMVZsOz8Jpr4nakFLkYIQwwJ73sBUkUJvH9ouIWmIu5bvgQrbQlRpxWTIY3e0Utlg==} + '@swc/core-win32-arm64-msvc@1.13.19': + resolution: {integrity: sha512-awcZSIuxyVn0Dw28VjMvgk1qiDJ6CeQwHkZNUjg2UxVlq23zE01NMMp+zkoGFypmLG9gaGmJSzuoqvk/WCQ5tw==} engines: {node: '>=10'} cpu: [arm64] os: [win32] - '@swc/core-win32-ia32-msvc@1.13.20': - resolution: {integrity: sha512-uVjjwGXJltUQK0v1qQSNGeMS6osLJuwgeTti5N7kxQ6mOfa1irxq+TX0YdIVQwIONMjzI+TP7lhqPeA9VdUjRg==} + '@swc/core-win32-ia32-msvc@1.13.19': + resolution: {integrity: sha512-H5d+KO7ISoLNgYvTbOcCQjJZNM3R7yaYlrMAF13lUr6GSiOUX+92xtM31B+HvzAWI7HtvVe74d29aC1b1TpXFA==} engines: {node: '>=10'} cpu: [ia32] os: [win32] - '@swc/core-win32-x64-msvc@1.13.20': - resolution: {integrity: sha512-Xm1JAew/P0TgsPSXyo60IH865fAmt9b2Mzd0FBJ77Q1xA1o/Oi9teCeGChyFq3+6JFao6uT0N4mcI3BJ4WBfkA==} + '@swc/core-win32-x64-msvc@1.13.19': + resolution: {integrity: sha512-qNoyCpXvv2O3JqXKanRIeoMn03Fho/As+N4Fhe7u0FsYh4VYqGQah4DGDzEP/yjl4Gx1IElhqLGDhCCGMwWaDw==} engines: {node: '>=10'} cpu: [x64] os: [win32] - '@swc/core@1.13.20': - resolution: {integrity: sha512-w6REE95NkGhQH/baA0reb6IQjVzSy5HOz9bZnRTFgOv+a1ZDo4p6yVs4McpFOZJeu810DSHayO3mwBsBXxZcaw==} + '@swc/core@1.13.19': + resolution: {integrity: sha512-V1r4wFdjaZIUIZZrV2Mb/prEeu03xvSm6oatPxsvnXKF9lNh5Jtk9QvUdiVfD9rrvi7bXrAVhg9Wpbmv/2Fl1g==} engines: {node: '>=10'} peerDependencies: '@swc/helpers': '>=0.5.17' @@ -10275,7 +10275,7 @@ snapshots: jest-util: 30.2.0 slash: 3.0.0 - '@jest/core@30.2.0(ts-node@10.9.2(@swc/core@1.13.20(@swc/helpers@0.5.17))(@types/node@22.18.10)(typescript@5.9.3))': + '@jest/core@30.2.0(ts-node@10.9.2(@swc/core@1.13.19(@swc/helpers@0.5.17))(@types/node@22.18.10)(typescript@5.9.3))': dependencies: '@jest/console': 30.2.0 '@jest/pattern': 30.0.1 @@ -10290,7 +10290,7 @@ snapshots: exit-x: 0.2.2 graceful-fs: 4.2.11 jest-changed-files: 30.2.0 - jest-config: 30.2.0(@types/node@22.18.10)(ts-node@10.9.2(@swc/core@1.13.20(@swc/helpers@0.5.17))(@types/node@22.18.10)(typescript@5.9.3)) + jest-config: 30.2.0(@types/node@22.18.10)(ts-node@10.9.2(@swc/core@1.13.19(@swc/helpers@0.5.17))(@types/node@22.18.10)(typescript@5.9.3)) jest-haste-map: 30.2.0 jest-message-util: 30.2.0 jest-regex-util: 30.0.1 @@ -11851,7 +11851,7 @@ snapshots: '@sentry/utils': 7.120.4 localforage: 1.10.0 - '@sentry/nextjs@10.19.0(@opentelemetry/context-async-hooks@2.1.0(@opentelemetry/api@1.9.0))(@opentelemetry/core@2.1.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.1.0(@opentelemetry/api@1.9.0))(next@15.5.4(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.56.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react@19.2.0)(webpack@5.101.3(@swc/core@1.13.20(@swc/helpers@0.5.17)))': + '@sentry/nextjs@10.19.0(@opentelemetry/context-async-hooks@2.1.0(@opentelemetry/api@1.9.0))(@opentelemetry/core@2.1.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.1.0(@opentelemetry/api@1.9.0))(next@15.5.4(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.56.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react@19.2.0)(webpack@5.101.3(@swc/core@1.13.19(@swc/helpers@0.5.17)))': dependencies: '@opentelemetry/api': 1.9.0 '@opentelemetry/semantic-conventions': 1.37.0 @@ -11863,7 +11863,7 @@ snapshots: '@sentry/opentelemetry': 10.19.0(@opentelemetry/api@1.9.0)(@opentelemetry/context-async-hooks@2.1.0(@opentelemetry/api@1.9.0))(@opentelemetry/core@2.1.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.1.0(@opentelemetry/api@1.9.0))(@opentelemetry/semantic-conventions@1.37.0) '@sentry/react': 10.19.0(react@19.2.0) '@sentry/vercel-edge': 10.19.0 - '@sentry/webpack-plugin': 4.4.0(webpack@5.101.3(@swc/core@1.13.20(@swc/helpers@0.5.17))) + '@sentry/webpack-plugin': 4.4.0(webpack@5.101.3(@swc/core@1.13.19(@swc/helpers@0.5.17))) chalk: 3.0.0 next: 15.5.4(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(@playwright/test@1.56.0)(react-dom@19.2.0(react@19.2.0))(react@19.2.0) resolve: 1.22.8 @@ -11970,12 +11970,12 @@ snapshots: '@opentelemetry/resources': 2.1.0(@opentelemetry/api@1.9.0) '@sentry/core': 10.19.0 - '@sentry/webpack-plugin@4.4.0(webpack@5.101.3(@swc/core@1.13.20(@swc/helpers@0.5.17)))': + '@sentry/webpack-plugin@4.4.0(webpack@5.101.3(@swc/core@1.13.19(@swc/helpers@0.5.17)))': dependencies: '@sentry/bundler-plugin-core': 4.4.0 unplugin: 1.0.1 uuid: 9.0.1 - webpack: 5.101.3(@swc/core@1.13.20(@swc/helpers@0.5.17)) + webpack: 5.101.3(@swc/core@1.13.19(@swc/helpers@0.5.17)) transitivePeerDependencies: - encoding - supports-color @@ -12011,51 +12011,51 @@ snapshots: dependencies: '@svgdotjs/svg.js': 3.2.5 - '@swc/core-darwin-arm64@1.13.20': + '@swc/core-darwin-arm64@1.13.19': optional: true - '@swc/core-darwin-x64@1.13.20': + '@swc/core-darwin-x64@1.13.19': optional: true - '@swc/core-linux-arm-gnueabihf@1.13.20': + '@swc/core-linux-arm-gnueabihf@1.13.19': optional: true - '@swc/core-linux-arm64-gnu@1.13.20': + '@swc/core-linux-arm64-gnu@1.13.19': optional: true - '@swc/core-linux-arm64-musl@1.13.20': + '@swc/core-linux-arm64-musl@1.13.19': optional: true - '@swc/core-linux-x64-gnu@1.13.20': + '@swc/core-linux-x64-gnu@1.13.19': optional: true - '@swc/core-linux-x64-musl@1.13.20': + '@swc/core-linux-x64-musl@1.13.19': optional: true - '@swc/core-win32-arm64-msvc@1.13.20': + '@swc/core-win32-arm64-msvc@1.13.19': optional: true - '@swc/core-win32-ia32-msvc@1.13.20': + '@swc/core-win32-ia32-msvc@1.13.19': optional: true - '@swc/core-win32-x64-msvc@1.13.20': + '@swc/core-win32-x64-msvc@1.13.19': optional: true - '@swc/core@1.13.20(@swc/helpers@0.5.17)': + '@swc/core@1.13.19(@swc/helpers@0.5.17)': dependencies: '@swc/counter': 0.1.3 '@swc/types': 0.1.25 optionalDependencies: - '@swc/core-darwin-arm64': 1.13.20 - '@swc/core-darwin-x64': 1.13.20 - '@swc/core-linux-arm-gnueabihf': 1.13.20 - '@swc/core-linux-arm64-gnu': 1.13.20 - '@swc/core-linux-arm64-musl': 1.13.20 - '@swc/core-linux-x64-gnu': 1.13.20 - '@swc/core-linux-x64-musl': 1.13.20 - '@swc/core-win32-arm64-msvc': 1.13.20 - '@swc/core-win32-ia32-msvc': 1.13.20 - '@swc/core-win32-x64-msvc': 1.13.20 + '@swc/core-darwin-arm64': 1.13.19 + '@swc/core-darwin-x64': 1.13.19 + '@swc/core-linux-arm-gnueabihf': 1.13.19 + '@swc/core-linux-arm64-gnu': 1.13.19 + '@swc/core-linux-arm64-musl': 1.13.19 + '@swc/core-linux-x64-gnu': 1.13.19 + '@swc/core-linux-x64-musl': 1.13.19 + '@swc/core-win32-arm64-msvc': 1.13.19 + '@swc/core-win32-ia32-msvc': 1.13.19 + '@swc/core-win32-x64-msvc': 1.13.19 '@swc/helpers': 0.5.17 '@swc/counter@0.1.3': {} @@ -12068,10 +12068,10 @@ snapshots: dependencies: tslib: 2.8.1 - '@swc/jest@0.2.39(@swc/core@1.13.20(@swc/helpers@0.5.17))': + '@swc/jest@0.2.39(@swc/core@1.13.19(@swc/helpers@0.5.17))': dependencies: '@jest/create-cache-key-function': 30.2.0 - '@swc/core': 1.13.20(@swc/helpers@0.5.17) + '@swc/core': 1.13.19(@swc/helpers@0.5.17) '@swc/counter': 0.1.3 jsonc-parser: 3.3.1 @@ -13749,13 +13749,13 @@ snapshots: - eslint-import-resolver-webpack - supports-color - eslint-plugin-jest@29.0.1(@typescript-eslint/eslint-plugin@8.46.0(@typescript-eslint/parser@8.46.0(eslint@9.37.0(jiti@2.6.1))(typescript@5.9.3))(eslint@9.37.0(jiti@2.6.1))(typescript@5.9.3))(eslint@9.37.0(jiti@2.6.1))(jest@30.2.0(@types/node@22.18.10)(ts-node@10.9.2(@swc/core@1.13.20(@swc/helpers@0.5.17))(@types/node@22.18.10)(typescript@5.9.3)))(typescript@5.9.3): + eslint-plugin-jest@29.0.1(@typescript-eslint/eslint-plugin@8.46.0(@typescript-eslint/parser@8.46.0(eslint@9.37.0(jiti@2.6.1))(typescript@5.9.3))(eslint@9.37.0(jiti@2.6.1))(typescript@5.9.3))(eslint@9.37.0(jiti@2.6.1))(jest@30.2.0(@types/node@22.18.10)(ts-node@10.9.2(@swc/core@1.13.19(@swc/helpers@0.5.17))(@types/node@22.18.10)(typescript@5.9.3)))(typescript@5.9.3): dependencies: '@typescript-eslint/utils': 8.46.0(eslint@9.37.0(jiti@2.6.1))(typescript@5.9.3) eslint: 9.37.0(jiti@2.6.1) optionalDependencies: '@typescript-eslint/eslint-plugin': 8.46.0(@typescript-eslint/parser@8.46.0(eslint@9.37.0(jiti@2.6.1))(typescript@5.9.3))(eslint@9.37.0(jiti@2.6.1))(typescript@5.9.3) - jest: 30.2.0(@types/node@22.18.10)(ts-node@10.9.2(@swc/core@1.13.20(@swc/helpers@0.5.17))(@types/node@22.18.10)(typescript@5.9.3)) + jest: 30.2.0(@types/node@22.18.10)(ts-node@10.9.2(@swc/core@1.13.19(@swc/helpers@0.5.17))(@types/node@22.18.10)(typescript@5.9.3)) transitivePeerDependencies: - supports-color - typescript @@ -14771,15 +14771,15 @@ snapshots: - babel-plugin-macros - supports-color - jest-cli@30.2.0(@types/node@22.18.10)(ts-node@10.9.2(@swc/core@1.13.20(@swc/helpers@0.5.17))(@types/node@22.18.10)(typescript@5.9.3)): + jest-cli@30.2.0(@types/node@22.18.10)(ts-node@10.9.2(@swc/core@1.13.19(@swc/helpers@0.5.17))(@types/node@22.18.10)(typescript@5.9.3)): dependencies: - '@jest/core': 30.2.0(ts-node@10.9.2(@swc/core@1.13.20(@swc/helpers@0.5.17))(@types/node@22.18.10)(typescript@5.9.3)) + '@jest/core': 30.2.0(ts-node@10.9.2(@swc/core@1.13.19(@swc/helpers@0.5.17))(@types/node@22.18.10)(typescript@5.9.3)) '@jest/test-result': 30.2.0 '@jest/types': 30.2.0 chalk: 4.1.2 exit-x: 0.2.2 import-local: 3.2.0 - jest-config: 30.2.0(@types/node@22.18.10)(ts-node@10.9.2(@swc/core@1.13.20(@swc/helpers@0.5.17))(@types/node@22.18.10)(typescript@5.9.3)) + jest-config: 30.2.0(@types/node@22.18.10)(ts-node@10.9.2(@swc/core@1.13.19(@swc/helpers@0.5.17))(@types/node@22.18.10)(typescript@5.9.3)) jest-util: 30.2.0 jest-validate: 30.2.0 yargs: 17.7.2 @@ -14790,7 +14790,7 @@ snapshots: - supports-color - ts-node - jest-config@30.2.0(@types/node@22.18.10)(ts-node@10.9.2(@swc/core@1.13.20(@swc/helpers@0.5.17))(@types/node@22.18.10)(typescript@5.9.3)): + jest-config@30.2.0(@types/node@22.18.10)(ts-node@10.9.2(@swc/core@1.13.19(@swc/helpers@0.5.17))(@types/node@22.18.10)(typescript@5.9.3)): dependencies: '@babel/core': 7.28.4 '@jest/get-type': 30.1.0 @@ -14818,7 +14818,7 @@ snapshots: strip-json-comments: 3.1.1 optionalDependencies: '@types/node': 22.18.10 - ts-node: 10.9.2(@swc/core@1.13.20(@swc/helpers@0.5.17))(@types/node@22.18.10)(typescript@5.9.3) + ts-node: 10.9.2(@swc/core@1.13.19(@swc/helpers@0.5.17))(@types/node@22.18.10)(typescript@5.9.3) transitivePeerDependencies: - babel-plugin-macros - supports-color @@ -15072,12 +15072,12 @@ snapshots: merge-stream: 2.0.0 supports-color: 8.1.1 - jest@30.2.0(@types/node@22.18.10)(ts-node@10.9.2(@swc/core@1.13.20(@swc/helpers@0.5.17))(@types/node@22.18.10)(typescript@5.9.3)): + jest@30.2.0(@types/node@22.18.10)(ts-node@10.9.2(@swc/core@1.13.19(@swc/helpers@0.5.17))(@types/node@22.18.10)(typescript@5.9.3)): dependencies: - '@jest/core': 30.2.0(ts-node@10.9.2(@swc/core@1.13.20(@swc/helpers@0.5.17))(@types/node@22.18.10)(typescript@5.9.3)) + '@jest/core': 30.2.0(ts-node@10.9.2(@swc/core@1.13.19(@swc/helpers@0.5.17))(@types/node@22.18.10)(typescript@5.9.3)) '@jest/types': 30.2.0 import-local: 3.2.0 - jest-cli: 30.2.0(@types/node@22.18.10)(ts-node@10.9.2(@swc/core@1.13.20(@swc/helpers@0.5.17))(@types/node@22.18.10)(typescript@5.9.3)) + jest-cli: 30.2.0(@types/node@22.18.10)(ts-node@10.9.2(@swc/core@1.13.19(@swc/helpers@0.5.17))(@types/node@22.18.10)(typescript@5.9.3)) transitivePeerDependencies: - '@types/node' - babel-plugin-macros @@ -16662,16 +16662,16 @@ snapshots: minizlib: 3.1.0 yallist: 5.0.0 - terser-webpack-plugin@5.3.14(@swc/core@1.13.20(@swc/helpers@0.5.17))(webpack@5.101.3(@swc/core@1.13.20(@swc/helpers@0.5.17))): + terser-webpack-plugin@5.3.14(@swc/core@1.13.19(@swc/helpers@0.5.17))(webpack@5.101.3(@swc/core@1.13.19(@swc/helpers@0.5.17))): dependencies: '@jridgewell/trace-mapping': 0.3.31 jest-worker: 27.5.1 schema-utils: 4.3.3 serialize-javascript: 6.0.2 terser: 5.44.0 - webpack: 5.101.3(@swc/core@1.13.20(@swc/helpers@0.5.17)) + webpack: 5.101.3(@swc/core@1.13.19(@swc/helpers@0.5.17)) optionalDependencies: - '@swc/core': 1.13.20(@swc/helpers@0.5.17) + '@swc/core': 1.13.19(@swc/helpers@0.5.17) terser@5.44.0: dependencies: @@ -16753,12 +16753,12 @@ snapshots: dependencies: typescript: 5.9.3 - ts-jest@29.4.5(@babel/core@7.28.4)(@jest/transform@30.2.0)(@jest/types@30.2.0)(babel-jest@30.2.0(@babel/core@7.28.4))(jest-util@30.2.0)(jest@30.2.0(@types/node@22.18.10)(ts-node@10.9.2(@swc/core@1.13.20(@swc/helpers@0.5.17))(@types/node@22.18.10)(typescript@5.9.3)))(typescript@5.9.3): + ts-jest@29.4.5(@babel/core@7.28.4)(@jest/transform@30.2.0)(@jest/types@30.2.0)(babel-jest@30.2.0(@babel/core@7.28.4))(jest-util@30.2.0)(jest@30.2.0(@types/node@22.18.10)(ts-node@10.9.2(@swc/core@1.13.19(@swc/helpers@0.5.17))(@types/node@22.18.10)(typescript@5.9.3)))(typescript@5.9.3): dependencies: bs-logger: 0.2.6 fast-json-stable-stringify: 2.1.0 handlebars: 4.7.8 - jest: 30.2.0(@types/node@22.18.10)(ts-node@10.9.2(@swc/core@1.13.20(@swc/helpers@0.5.17))(@types/node@22.18.10)(typescript@5.9.3)) + jest: 30.2.0(@types/node@22.18.10)(ts-node@10.9.2(@swc/core@1.13.19(@swc/helpers@0.5.17))(@types/node@22.18.10)(typescript@5.9.3)) json5: 2.2.3 lodash.memoize: 4.1.2 make-error: 1.3.6 @@ -16775,7 +16775,7 @@ snapshots: ts-log@2.2.7: {} - ts-node@10.9.2(@swc/core@1.13.20(@swc/helpers@0.5.17))(@types/node@22.18.10)(typescript@5.9.3): + ts-node@10.9.2(@swc/core@1.13.19(@swc/helpers@0.5.17))(@types/node@22.18.10)(typescript@5.9.3): dependencies: '@cspotcode/source-map-support': 0.8.1 '@tsconfig/node10': 1.0.11 @@ -16793,7 +16793,7 @@ snapshots: v8-compile-cache-lib: 3.0.1 yn: 3.1.1 optionalDependencies: - '@swc/core': 1.13.20(@swc/helpers@0.5.17) + '@swc/core': 1.13.19(@swc/helpers@0.5.17) tsconfig-paths@3.15.0: dependencies: @@ -17030,7 +17030,7 @@ snapshots: webpack-virtual-modules@0.5.0: {} - webpack@5.101.3(@swc/core@1.13.20(@swc/helpers@0.5.17)): + webpack@5.101.3(@swc/core@1.13.19(@swc/helpers@0.5.17)): dependencies: '@types/eslint-scope': 3.7.7 '@types/estree': 1.0.8 @@ -17054,7 +17054,7 @@ snapshots: neo-async: 2.6.2 schema-utils: 4.3.3 tapable: 2.3.0 - terser-webpack-plugin: 5.3.14(@swc/core@1.13.20(@swc/helpers@0.5.17))(webpack@5.101.3(@swc/core@1.13.20(@swc/helpers@0.5.17))) + terser-webpack-plugin: 5.3.14(@swc/core@1.13.19(@swc/helpers@0.5.17))(webpack@5.101.3(@swc/core@1.13.19(@swc/helpers@0.5.17))) watchpack: 2.4.4 webpack-sources: 3.3.3 transitivePeerDependencies: diff --git a/frontend/src/app/members/[memberKey]/page.tsx b/frontend/src/app/members/[memberKey]/page.tsx index 094bf53ddc..5936f9609f 100644 --- a/frontend/src/app/members/[memberKey]/page.tsx +++ b/frontend/src/app/members/[memberKey]/page.tsx @@ -12,7 +12,9 @@ import { useParams } from 'next/navigation' import { useTheme } from 'next-themes' import React, { useState, useEffect, useRef } from 'react' import { handleAppError, ErrorDisplay } from 'app/global-error' + import { GetUserDataDocument } from 'types/__generated__/userQueries.generated' +import { Badge } from 'types/badge' import type { Issue } from 'types/issue' import type { Milestone } from 'types/milestone' import type { RepositoryCardProps } from 'types/project' @@ -21,6 +23,7 @@ import type { Release } from 'types/release' import type { User } from 'types/user' import { formatDate } from 'utils/dateFormatter' import { drawContributions, fetchHeatmapData, HeatmapData } from 'utils/helpers/githubHeatmap' +import Badges from 'components/Badges' import DetailsCard from 'components/CardDetailsPage' import LoadingSpinner from 'components/LoadingSpinner' @@ -185,24 +188,39 @@ const UserDetailsPage: React.FC = () => { } const UserSummary = () => ( -
-
- {user?.name -
-
-
- - @{user?.login} - +
+ {user?.name +
+
+
+ + @{user?.login} + + {user?.badges && user.badges.length > 0 && ( +
+ {user.badges.slice().map((badge: Badge) => ( + + + + ))} +
+ )} +

{formattedBio}

- {!isPrivateContributor && (
diff --git a/frontend/src/app/members/page.tsx b/frontend/src/app/members/page.tsx index 955210d6d6..88ca788835 100644 --- a/frontend/src/app/members/page.tsx +++ b/frontend/src/app/members/page.tsx @@ -22,7 +22,6 @@ const UsersPage = () => { }) const router = useRouter() - const handleButtonClick = (user: User) => { router.push(`/members/${user.key}`) } @@ -34,9 +33,12 @@ const UsersPage = () => { onclick: () => handleButtonClick(user), } + const badgeCount = user.badgeCount || 0 return ( { + if (!cssClass || cssClass.trim() === '') { + return '' + } + + // Convert backend snake_case format to frontend camelCase format + return cssClass.trim().replace(/_([a-z])/g, (_, letter) => letter.toUpperCase()) +} + +const resolveIcon = (cssClass: string | undefined) => { + const normalizedClass = normalizeCssClass(cssClass) + return BADGE_CLASS_MAP[normalizedClass] ?? DEFAULT_ICON +} + +const Badges = ({ name, cssClass, showTooltip = true }: BadgeProps) => { + const icon = resolveIcon(cssClass) + + return ( +
+ + + +
+ ) +} + +export default Badges diff --git a/frontend/src/components/UserCard.tsx b/frontend/src/components/UserCard.tsx index 4df1aec7b6..34ed8b1f77 100644 --- a/frontend/src/components/UserCard.tsx +++ b/frontend/src/components/UserCard.tsx @@ -1,4 +1,10 @@ -import { faChevronRight, faFolderOpen, faUser, faUsers } from '@fortawesome/free-solid-svg-icons' +import { + faChevronRight, + faFolderOpen, + faMedal, + faUser, + faUsers, +} from '@fortawesome/free-solid-svg-icons' import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' import { Button } from '@heroui/button' import millify from 'millify' @@ -7,6 +13,7 @@ import type { UserCardProps } from 'types/card' const UserCard = ({ avatar, + badgeCount, button, className, company, @@ -62,6 +69,12 @@ const UserCard = ({ {millify(repositoriesCount, { precision: 1 })}

)} + {badgeCount > 0 && ( +

+ + {millify(badgeCount, { precision: 1 })}{' '} +

+ )}
diff --git a/frontend/src/server/queries/userQueries.ts b/frontend/src/server/queries/userQueries.ts index dd9c24cb76..73414bb66e 100644 --- a/frontend/src/server/queries/userQueries.ts +++ b/frontend/src/server/queries/userQueries.ts @@ -7,6 +7,14 @@ export const GET_LEADER_DATA = gql` avatarUrl login name + badgeCount + badges { + cssClass + description + id + name + weight + } } } ` @@ -65,8 +73,15 @@ export const GET_USER_DATA = gql` url } user(login: $key) { - id avatarUrl + badgeCount + badges { + cssClass + description + id + name + weight + } bio company contributionsCount @@ -74,6 +89,7 @@ export const GET_USER_DATA = gql` email followersCount followingCount + id issuesCount location login @@ -88,8 +104,9 @@ export const GET_USER_DATA = gql` export const GET_USER_METADATA = gql` query GetUserMetadata($key: String!) { user(login: $key) { - id + badgeCount bio + id login name } diff --git a/frontend/src/types/__generated__/graphql.ts b/frontend/src/types/__generated__/graphql.ts index bf05f4842f..16a0815080 100644 --- a/frontend/src/types/__generated__/graphql.ts +++ b/frontend/src/types/__generated__/graphql.ts @@ -877,6 +877,7 @@ export type UpdateProgramStatusInput = { export type UserNode = { __typename?: 'UserNode'; avatarUrl: Scalars['String']['output']; + badgeCount: Scalars['Int']['output']; badges: Array; bio: Scalars['String']['output']; company: Scalars['String']['output']; diff --git a/frontend/src/types/__generated__/userQueries.generated.ts b/frontend/src/types/__generated__/userQueries.generated.ts index 8516cb0dae..c590328d1b 100644 --- a/frontend/src/types/__generated__/userQueries.generated.ts +++ b/frontend/src/types/__generated__/userQueries.generated.ts @@ -6,23 +6,23 @@ export type GetLeaderDataQueryVariables = Types.Exact<{ }>; -export type GetLeaderDataQuery = { user: { __typename: 'UserNode', id: string, avatarUrl: string, login: string, name: string } | null }; +export type GetLeaderDataQuery = { user: { __typename: 'UserNode', id: string, avatarUrl: string, login: string, name: string, badgeCount: number, badges: Array<{ __typename: 'BadgeNode', cssClass: string, description: string, id: string, name: string, weight: number }> } | null }; export type GetUserDataQueryVariables = Types.Exact<{ key: Types.Scalars['String']['input']; }>; -export type GetUserDataQuery = { recentIssues: Array<{ __typename: 'IssueNode', id: string, createdAt: any, organizationName: string | null, repositoryName: string | null, title: string, url: string }>, recentMilestones: Array<{ __typename: 'MilestoneNode', id: string, title: string, openIssuesCount: number, closedIssuesCount: number, repositoryName: string | null, organizationName: string | null, createdAt: any, url: string }>, recentPullRequests: Array<{ __typename: 'PullRequestNode', id: string, createdAt: any, organizationName: string | null, repositoryName: string | null, title: string, url: string }>, recentReleases: Array<{ __typename: 'ReleaseNode', id: string, isPreRelease: boolean, name: string, publishedAt: any | null, organizationName: string | null, repositoryName: string | null, tagName: string, url: string }>, topContributedRepositories: Array<{ __typename: 'RepositoryNode', id: string, contributorsCount: number, forksCount: number, key: string, name: string, openIssuesCount: number, starsCount: number, subscribersCount: number, url: string, organization: { __typename: 'OrganizationNode', id: string, login: string } | null }>, user: { __typename: 'UserNode', id: string, avatarUrl: string, bio: string, company: string, contributionsCount: number, createdAt: number, email: string, followersCount: number, followingCount: number, issuesCount: number, location: string, login: string, name: string, publicRepositoriesCount: number, releasesCount: number, updatedAt: number, url: string } | null }; +export type GetUserDataQuery = { recentIssues: Array<{ __typename: 'IssueNode', id: string, createdAt: any, organizationName: string | null, repositoryName: string | null, title: string, url: string }>, recentMilestones: Array<{ __typename: 'MilestoneNode', id: string, title: string, openIssuesCount: number, closedIssuesCount: number, repositoryName: string | null, organizationName: string | null, createdAt: any, url: string }>, recentPullRequests: Array<{ __typename: 'PullRequestNode', id: string, createdAt: any, organizationName: string | null, repositoryName: string | null, title: string, url: string }>, recentReleases: Array<{ __typename: 'ReleaseNode', id: string, isPreRelease: boolean, name: string, publishedAt: any | null, organizationName: string | null, repositoryName: string | null, tagName: string, url: string }>, topContributedRepositories: Array<{ __typename: 'RepositoryNode', id: string, contributorsCount: number, forksCount: number, key: string, name: string, openIssuesCount: number, starsCount: number, subscribersCount: number, url: string, organization: { __typename: 'OrganizationNode', id: string, login: string } | null }>, user: { __typename: 'UserNode', avatarUrl: string, badgeCount: number, bio: string, company: string, contributionsCount: number, createdAt: number, email: string, followersCount: number, followingCount: number, id: string, issuesCount: number, location: string, login: string, name: string, publicRepositoriesCount: number, releasesCount: number, updatedAt: number, url: string, badges: Array<{ __typename: 'BadgeNode', cssClass: string, description: string, id: string, name: string, weight: number }> } | null }; export type GetUserMetadataQueryVariables = Types.Exact<{ key: Types.Scalars['String']['input']; }>; -export type GetUserMetadataQuery = { user: { __typename: 'UserNode', id: string, bio: string, login: string, name: string } | null }; +export type GetUserMetadataQuery = { user: { __typename: 'UserNode', badgeCount: number, bio: string, id: string, login: string, name: string } | null }; -export const GetLeaderDataDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetLeaderData"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"key"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"user"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"login"},"value":{"kind":"Variable","name":{"kind":"Name","value":"key"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"avatarUrl"}},{"kind":"Field","name":{"kind":"Name","value":"login"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}}]}}]} as unknown as DocumentNode; -export const GetUserDataDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetUserData"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"key"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"recentIssues"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"limit"},"value":{"kind":"IntValue","value":"5"}},{"kind":"Argument","name":{"kind":"Name","value":"login"},"value":{"kind":"Variable","name":{"kind":"Name","value":"key"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"organizationName"}},{"kind":"Field","name":{"kind":"Name","value":"repositoryName"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"url"}}]}},{"kind":"Field","name":{"kind":"Name","value":"recentMilestones"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"limit"},"value":{"kind":"IntValue","value":"5"}},{"kind":"Argument","name":{"kind":"Name","value":"login"},"value":{"kind":"Variable","name":{"kind":"Name","value":"key"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"openIssuesCount"}},{"kind":"Field","name":{"kind":"Name","value":"closedIssuesCount"}},{"kind":"Field","name":{"kind":"Name","value":"repositoryName"}},{"kind":"Field","name":{"kind":"Name","value":"organizationName"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"url"}}]}},{"kind":"Field","name":{"kind":"Name","value":"recentPullRequests"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"limit"},"value":{"kind":"IntValue","value":"5"}},{"kind":"Argument","name":{"kind":"Name","value":"login"},"value":{"kind":"Variable","name":{"kind":"Name","value":"key"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"organizationName"}},{"kind":"Field","name":{"kind":"Name","value":"repositoryName"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"url"}}]}},{"kind":"Field","name":{"kind":"Name","value":"recentReleases"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"limit"},"value":{"kind":"IntValue","value":"5"}},{"kind":"Argument","name":{"kind":"Name","value":"login"},"value":{"kind":"Variable","name":{"kind":"Name","value":"key"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"isPreRelease"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"publishedAt"}},{"kind":"Field","name":{"kind":"Name","value":"organizationName"}},{"kind":"Field","name":{"kind":"Name","value":"repositoryName"}},{"kind":"Field","name":{"kind":"Name","value":"tagName"}},{"kind":"Field","name":{"kind":"Name","value":"url"}}]}},{"kind":"Field","name":{"kind":"Name","value":"topContributedRepositories"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"login"},"value":{"kind":"Variable","name":{"kind":"Name","value":"key"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"contributorsCount"}},{"kind":"Field","name":{"kind":"Name","value":"forksCount"}},{"kind":"Field","name":{"kind":"Name","value":"key"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"openIssuesCount"}},{"kind":"Field","name":{"kind":"Name","value":"organization"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"login"}}]}},{"kind":"Field","name":{"kind":"Name","value":"starsCount"}},{"kind":"Field","name":{"kind":"Name","value":"subscribersCount"}},{"kind":"Field","name":{"kind":"Name","value":"url"}}]}},{"kind":"Field","name":{"kind":"Name","value":"user"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"login"},"value":{"kind":"Variable","name":{"kind":"Name","value":"key"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"avatarUrl"}},{"kind":"Field","name":{"kind":"Name","value":"bio"}},{"kind":"Field","name":{"kind":"Name","value":"company"}},{"kind":"Field","name":{"kind":"Name","value":"contributionsCount"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"email"}},{"kind":"Field","name":{"kind":"Name","value":"followersCount"}},{"kind":"Field","name":{"kind":"Name","value":"followingCount"}},{"kind":"Field","name":{"kind":"Name","value":"issuesCount"}},{"kind":"Field","name":{"kind":"Name","value":"location"}},{"kind":"Field","name":{"kind":"Name","value":"login"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"publicRepositoriesCount"}},{"kind":"Field","name":{"kind":"Name","value":"releasesCount"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}},{"kind":"Field","name":{"kind":"Name","value":"url"}}]}}]}}]} as unknown as DocumentNode; -export const GetUserMetadataDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetUserMetadata"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"key"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"user"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"login"},"value":{"kind":"Variable","name":{"kind":"Name","value":"key"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"bio"}},{"kind":"Field","name":{"kind":"Name","value":"login"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}}]}}]} as unknown as DocumentNode; \ No newline at end of file +export const GetLeaderDataDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetLeaderData"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"key"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"user"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"login"},"value":{"kind":"Variable","name":{"kind":"Name","value":"key"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"avatarUrl"}},{"kind":"Field","name":{"kind":"Name","value":"login"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"badgeCount"}},{"kind":"Field","name":{"kind":"Name","value":"badges"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"cssClass"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"weight"}}]}}]}}]}}]} as unknown as DocumentNode; +export const GetUserDataDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetUserData"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"key"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"recentIssues"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"limit"},"value":{"kind":"IntValue","value":"5"}},{"kind":"Argument","name":{"kind":"Name","value":"login"},"value":{"kind":"Variable","name":{"kind":"Name","value":"key"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"organizationName"}},{"kind":"Field","name":{"kind":"Name","value":"repositoryName"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"url"}}]}},{"kind":"Field","name":{"kind":"Name","value":"recentMilestones"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"limit"},"value":{"kind":"IntValue","value":"5"}},{"kind":"Argument","name":{"kind":"Name","value":"login"},"value":{"kind":"Variable","name":{"kind":"Name","value":"key"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"openIssuesCount"}},{"kind":"Field","name":{"kind":"Name","value":"closedIssuesCount"}},{"kind":"Field","name":{"kind":"Name","value":"repositoryName"}},{"kind":"Field","name":{"kind":"Name","value":"organizationName"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"url"}}]}},{"kind":"Field","name":{"kind":"Name","value":"recentPullRequests"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"limit"},"value":{"kind":"IntValue","value":"5"}},{"kind":"Argument","name":{"kind":"Name","value":"login"},"value":{"kind":"Variable","name":{"kind":"Name","value":"key"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"organizationName"}},{"kind":"Field","name":{"kind":"Name","value":"repositoryName"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"url"}}]}},{"kind":"Field","name":{"kind":"Name","value":"recentReleases"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"limit"},"value":{"kind":"IntValue","value":"5"}},{"kind":"Argument","name":{"kind":"Name","value":"login"},"value":{"kind":"Variable","name":{"kind":"Name","value":"key"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"isPreRelease"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"publishedAt"}},{"kind":"Field","name":{"kind":"Name","value":"organizationName"}},{"kind":"Field","name":{"kind":"Name","value":"repositoryName"}},{"kind":"Field","name":{"kind":"Name","value":"tagName"}},{"kind":"Field","name":{"kind":"Name","value":"url"}}]}},{"kind":"Field","name":{"kind":"Name","value":"topContributedRepositories"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"login"},"value":{"kind":"Variable","name":{"kind":"Name","value":"key"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"contributorsCount"}},{"kind":"Field","name":{"kind":"Name","value":"forksCount"}},{"kind":"Field","name":{"kind":"Name","value":"key"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"openIssuesCount"}},{"kind":"Field","name":{"kind":"Name","value":"organization"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"login"}}]}},{"kind":"Field","name":{"kind":"Name","value":"starsCount"}},{"kind":"Field","name":{"kind":"Name","value":"subscribersCount"}},{"kind":"Field","name":{"kind":"Name","value":"url"}}]}},{"kind":"Field","name":{"kind":"Name","value":"user"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"login"},"value":{"kind":"Variable","name":{"kind":"Name","value":"key"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"avatarUrl"}},{"kind":"Field","name":{"kind":"Name","value":"badgeCount"}},{"kind":"Field","name":{"kind":"Name","value":"badges"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"cssClass"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"weight"}}]}},{"kind":"Field","name":{"kind":"Name","value":"bio"}},{"kind":"Field","name":{"kind":"Name","value":"company"}},{"kind":"Field","name":{"kind":"Name","value":"contributionsCount"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"email"}},{"kind":"Field","name":{"kind":"Name","value":"followersCount"}},{"kind":"Field","name":{"kind":"Name","value":"followingCount"}},{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"issuesCount"}},{"kind":"Field","name":{"kind":"Name","value":"location"}},{"kind":"Field","name":{"kind":"Name","value":"login"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"publicRepositoriesCount"}},{"kind":"Field","name":{"kind":"Name","value":"releasesCount"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}},{"kind":"Field","name":{"kind":"Name","value":"url"}}]}}]}}]} as unknown as DocumentNode; +export const GetUserMetadataDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetUserMetadata"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"key"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"user"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"login"},"value":{"kind":"Variable","name":{"kind":"Name","value":"key"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"badgeCount"}},{"kind":"Field","name":{"kind":"Name","value":"bio"}},{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"login"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}}]}}]} as unknown as DocumentNode; \ No newline at end of file diff --git a/frontend/src/types/badge.ts b/frontend/src/types/badge.ts new file mode 100644 index 0000000000..9fb95f689a --- /dev/null +++ b/frontend/src/types/badge.ts @@ -0,0 +1,7 @@ +export type Badge = { + readonly cssClass?: string + readonly description?: string + readonly id: string + readonly name: string + readonly weight: number +} diff --git a/frontend/src/types/card.ts b/frontend/src/types/card.ts index 46d3d79abe..8e9125989e 100644 --- a/frontend/src/types/card.ts +++ b/frontend/src/types/card.ts @@ -1,5 +1,6 @@ import type { IconDefinition } from '@fortawesome/free-solid-svg-icons' import type { JSX } from 'react' +import type { Badge } from 'types/badge' import type { Button } from 'types/button' import type { Chapter } from 'types/chapter' import type { Contributor } from 'types/contributor' @@ -78,6 +79,8 @@ export interface DetailsCardProps { export interface UserCardProps { avatar: string + badgeCount?: number + badges?: Badge[] button: Button className?: string company?: string diff --git a/frontend/src/types/user.ts b/frontend/src/types/user.ts index 349618b99e..293933f89f 100644 --- a/frontend/src/types/user.ts +++ b/frontend/src/types/user.ts @@ -1,3 +1,4 @@ +import type { Badge } from 'types/badge' import type { Issue } from 'types/issue' import type { RepositoryCardProps } from 'types/project' import type { Release } from 'types/release' @@ -9,6 +10,8 @@ export type RepositoryDetails = { export type User = { avatarUrl: string + badgeCount?: number + badges?: Badge[] bio?: string company?: string contributionsCount?: number diff --git a/frontend/src/utils/data.ts b/frontend/src/utils/data.ts index 1be07c97ff..837ac1d829 100644 --- a/frontend/src/utils/data.ts +++ b/frontend/src/utils/data.ts @@ -1,3 +1,4 @@ +import type { IconDefinition } from '@fortawesome/fontawesome-svg-core' import { library } from '@fortawesome/fontawesome-svg-core' import { faDiscord, @@ -6,8 +7,8 @@ import { faLinkedin, faMeetup, faSlack, - faYoutube, faXTwitter, + faYoutube, } from '@fortawesome/free-brands-svg-icons' import { faClock, @@ -18,6 +19,9 @@ import { } from '@fortawesome/free-regular-svg-icons' import { faArrowsRotate, + faAward, + faBug, + faCertificate, faCity, faCode, faCodeFork, @@ -25,44 +29,61 @@ import { faFlag, faFlask, faGlobe, + faMedal, faMoon, + faPeopleGroup, + faRibbon, faRightToBracket, + faStar as faSolidStar, + faSun, faWandMagicSparkles, faX, - faPeopleGroup, - faSun, } from '@fortawesome/free-solid-svg-icons' library.add( faArrowsRotate, - faCodeFork, - faStar, - faUser, + faAward, + faBug, + faCertificate, + faCity, faClock, + faCode, + faCodeFork, faComment, + faDiscord, faEgg, - faFlask, - faCity, + faFacebook, faFlag, - faCode, - faMoon, - faLightbulb, - faWandMagicSparkles, + faFlask, faGlobe, - faRightToBracket, - faYoutube, - faX, faGoogle, - faMeetup, + faLightbulb, faLinkedin, - faFacebook, - faDiscord, - faSlack, + faMedal, + faMeetup, + faMoon, faPeopleGroup, + faRibbon, + faRightToBracket, + faSlack, + faStar, + faSun, + faUser, + faWandMagicSparkles, + faX, faXTwitter, - faSun + faYoutube ) +export const BADGE_CLASS_MAP: Record = { + award: faAward, + bugSlash: faBug, + certificate: faCertificate, + medal: faMedal, + ribbon: faRibbon, + star: faSolidStar, +} as const + export const ICONS = { starsCount: { label: 'GitHub stars',