diff --git a/frontend/__tests__/unit/pages/OrganizationDetails.test.tsx b/frontend/__tests__/unit/pages/OrganizationDetails.test.tsx index 3881dd3d55..7b96134ef4 100644 --- a/frontend/__tests__/unit/pages/OrganizationDetails.test.tsx +++ b/frontend/__tests__/unit/pages/OrganizationDetails.test.tsx @@ -51,14 +51,15 @@ describe('OrganizationDetailsPage', () => { test('renders loading state', async () => { ;(useQuery as unknown as jest.Mock).mockReturnValue({ data: null, + loading: true, error: null, }) render() - const loadingSpinner = screen.getAllByAltText('Loading indicator') + // Use semantic role query instead of CSS selectors for better stability await waitFor(() => { - expect(loadingSpinner.length).toBeGreaterThan(0) + expect(screen.getByTestId('org-loading-skeleton')).toBeInTheDocument() }) }) @@ -201,6 +202,7 @@ describe('OrganizationDetailsPage', () => { }) }) }) + test('does not render sponsor block', async () => { ;(useQuery as unknown as jest.Mock).mockReturnValue({ data: mockOrganizationDetailsData, diff --git a/frontend/__tests__/unit/pages/UserDetails.test.tsx b/frontend/__tests__/unit/pages/UserDetails.test.tsx index 3af7b6d26a..0181f2b598 100644 --- a/frontend/__tests__/unit/pages/UserDetails.test.tsx +++ b/frontend/__tests__/unit/pages/UserDetails.test.tsx @@ -114,13 +114,15 @@ describe('UserDetailsPage', () => { test('renders loading state', async () => { ;(useQuery as unknown as jest.Mock).mockReturnValue({ data: null, + loading: true, error: null, }) render() - const loadingSpinner = screen.getAllByAltText('Loading indicator') + + // Use semantic role query instead of CSS selectors for better stability await waitFor(() => { - expect(loadingSpinner.length).toBeGreaterThan(0) + expect(screen.getByTestId('user-loading-skeleton')).toBeInTheDocument() }) }) @@ -137,10 +139,9 @@ describe('UserDetailsPage', () => { render() await waitFor(() => { - expect(screen.queryByAltText('Loading indicator')).not.toBeInTheDocument() + expect(screen.getByText('Test User')).toBeInTheDocument() }) - expect(screen.getByText('Test User')).toBeInTheDocument() expect(screen.getByText('Statistics')).toBeInTheDocument() expect(screen.getByText('Test Company')).toBeInTheDocument() expect(screen.getByText('Test Location')).toBeInTheDocument() @@ -540,6 +541,7 @@ describe('UserDetailsPage', () => { expect(bioContainer).toHaveClass('lg:text-left') }) }) + test('does not render sponsor block', async () => { ;(useQuery as unknown as jest.Mock).mockReturnValue({ data: mockUserDetailsData, diff --git a/frontend/src/app/members/[memberKey]/page.tsx b/frontend/src/app/members/[memberKey]/page.tsx index e4a56e148d..62fb71362a 100644 --- a/frontend/src/app/members/[memberKey]/page.tsx +++ b/frontend/src/app/members/[memberKey]/page.tsx @@ -25,7 +25,7 @@ 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' +import MemberDetailsPageSkeleton from 'components/skeletons/MemberDetailsPageSkeleton' const UserDetailsPage: React.FC = () => { const { memberKey } = useParams<{ memberKey: string }>() @@ -100,7 +100,11 @@ const UserDetailsPage: React.FC = () => { }) if (isLoading) { - return + return ( +
+ +
+ ) } if (!isLoading && user == null) { diff --git a/frontend/src/app/organizations/[organizationKey]/page.tsx b/frontend/src/app/organizations/[organizationKey]/page.tsx index 1541f91d95..052606b050 100644 --- a/frontend/src/app/organizations/[organizationKey]/page.tsx +++ b/frontend/src/app/organizations/[organizationKey]/page.tsx @@ -14,7 +14,7 @@ import { handleAppError, ErrorDisplay } from 'app/global-error' import { GetOrganizationDataDocument } from 'types/__generated__/organizationQueries.generated' import { formatDate } from 'utils/dateFormatter' import DetailsCard from 'components/CardDetailsPage' -import LoadingSpinner from 'components/LoadingSpinner' +import OrganizationDetailsPageSkeleton from 'components/skeletons/OrganizationDetailsPageSkeleton' const OrganizationDetailsPage = () => { const { organizationKey } = useParams<{ organizationKey: string }>() const [organization, setOrganization] = useState(null) @@ -47,7 +47,11 @@ const OrganizationDetailsPage = () => { }, [graphQLData, graphQLRequestError, organizationKey]) if (isLoading) { - return + return ( +
+ +
+ ) } if (!isLoading && !graphQLData?.organization) { diff --git a/frontend/src/components/SkeletonsBase.tsx b/frontend/src/components/SkeletonsBase.tsx index 7d9fb64395..96577819bd 100644 --- a/frontend/src/components/SkeletonsBase.tsx +++ b/frontend/src/components/SkeletonsBase.tsx @@ -2,9 +2,10 @@ import { Skeleton } from '@heroui/skeleton' import LoadingSpinner from 'components/LoadingSpinner' import AboutSkeleton from 'components/skeletons/AboutSkeleton' import CardSkeleton from 'components/skeletons/Card' +import MemberDetailsPageSkeleton from 'components/skeletons/MemberDetailsPageSkeleton' +import OrganizationDetailsPageSkeleton from 'components/skeletons/OrganizationDetailsPageSkeleton' import SnapshotSkeleton from 'components/skeletons/SnapshotSkeleton' import UserCardSkeleton from 'components/skeletons/UserCard' - function userCardRender() { const cardCount = 12 return ( @@ -68,6 +69,11 @@ const SkeletonBase = ({ return snapshotCardRender() case 'about': return + case 'member-details': + case 'members': + return + case 'organizations-details': + return default: return } diff --git a/frontend/src/components/skeletons/MemberDetailsPageSkeleton.tsx b/frontend/src/components/skeletons/MemberDetailsPageSkeleton.tsx new file mode 100644 index 0000000000..387b295924 --- /dev/null +++ b/frontend/src/components/skeletons/MemberDetailsPageSkeleton.tsx @@ -0,0 +1,163 @@ +import { Skeleton } from '@heroui/skeleton' +import React from 'react' +import { + CardSection, + PageWrapper, + SectionHeader, + TitleSection, + TwoColumnSection, +} from 'components/skeletons/sharedSkeletons' + +const MemberDetailsPageSkeleton: React.FC = () => { + return ( + + + + {/* User Summary Card - bg-gray-100 like SecondaryCard */} +
+
+ {/* Avatar */} +
+
+ + {/* Main Grid - User Details and Statistics */} +
+ {/* User Details Card */} + + +
+ {[1, 2, 3, 4].map((i) => ( +
+
+
+
+ ))} +
+
+ + {/* Statistics Card */} + + +
+ {[1, 2, 3, 4].map((i) => ( +
+
+
+
+
+
+ ))} +
+
+
+ + {/* Two Column Layout - Issues and Milestones */} + + + {/* Two Column Layout - Pull Requests and Releases */} + + + {/* Repositories Section */} + + +
+
+ {[1, 2, 3, 4].map((i) => ( +
+
+ ))} +
+
+
+
+
+
+ ) +} + +export default MemberDetailsPageSkeleton diff --git a/frontend/src/components/skeletons/OrganizationDetailsPageSkeleton.tsx b/frontend/src/components/skeletons/OrganizationDetailsPageSkeleton.tsx new file mode 100644 index 0000000000..77fdd8ff62 --- /dev/null +++ b/frontend/src/components/skeletons/OrganizationDetailsPageSkeleton.tsx @@ -0,0 +1,121 @@ +import { Skeleton } from '@heroui/skeleton' +import { + CardSection, + PageWrapper, + SectionHeader, + TitleSection, + TwoColumnSection, +} from 'components/skeletons/sharedSkeletons' + +const OrganizationDetailsPageSkeleton = () => { + return ( + + + + {/* Description - empty for organization */} +
+ + {/* Main Grid - Organization Details and Statistics */} +
+ {/* Organization Details - Takes 5 columns */} + + +
+ {Array.from({ length: 4 }, (_, i) => ( +
+
+ ))} +
+
+ + {/* Statistics - Takes 2 columns */} + + +
+ {Array.from({ length: 5 }, (_, i) => ( +
+
+ ))} +
+
+
+ + {/* Top Contributors */} + + +
+ {Array.from({ length: 12 }, (_, i) => ( +
+
+
+
+ ))} +
+
+ + {/* Two Column Layout - Recent Issues and Milestones */} + + + {/* Two Column Layout - Pull Requests and Releases */} + + + {/* Repositories */} + + +
+ {Array.from({ length: 4 }, (_, i) => ( +
+
+ ))} +
+
+
+
+
+ ) +} + +export default OrganizationDetailsPageSkeleton diff --git a/frontend/src/components/skeletons/sharedSkeletons.tsx b/frontend/src/components/skeletons/sharedSkeletons.tsx new file mode 100644 index 0000000000..72e2d2fb6b --- /dev/null +++ b/frontend/src/components/skeletons/sharedSkeletons.tsx @@ -0,0 +1,129 @@ +import { Skeleton } from '@heroui/skeleton' +import type { ReactNode } from 'react' + +export const ItemCardSkeleton = ({ titleWidth }: { titleWidth: string }) => ( +
+
+) + +export const SectionSkeleton = ({ + titleWidth, + itemCount, + itemKeyPrefix, + titleSkeletonWidth, + minHeight, +}: { + titleWidth: string + itemCount: number + itemKeyPrefix: string + titleSkeletonWidth: string + minHeight?: string +}) => ( +
+

+

+
+ {Array.from({ length: itemCount }, (_, i) => ( + + ))} +
+
+) + +export const PageWrapper = ({ + children, + ariaBusy = true, +}: { + children: ReactNode + ariaBusy?: boolean +}) => ( +
+
{children}
+
+) + +export const TitleSection = ({ + skeletonClassName = 'h-10 w-64 rounded', +}: { + skeletonClassName?: string +}) => ( +
+
+
+
+) + +export const TwoColumnSection = ({ + sections, +}: { + sections: Array<{ + keyPrefix: string + titleWidth: string + itemTitleWidth: string + minHeight?: string + }> +}) => ( +
+ {sections.map(({ keyPrefix, titleWidth, itemTitleWidth, minHeight }) => ( + + ))} +
+) + +export const SectionHeader = ({ + titleSkeletonWidth, + rounded = false, +}: { + titleSkeletonWidth: string + rounded?: boolean +}) => ( +

+

+) + +export const CardSection = ({ + children, + className = '', + minHeight, + colSpan, +}: { + children: ReactNode + className?: string + minHeight?: string + colSpan?: string +}) => ( +
+ {children} +
+)