diff --git a/frontend/__tests__/unit/global-error.test.tsx b/frontend/__tests__/unit/global-error.test.tsx index 9783dd9baf..b227e82f56 100644 --- a/frontend/__tests__/unit/global-error.test.tsx +++ b/frontend/__tests__/unit/global-error.test.tsx @@ -10,6 +10,7 @@ import GlobalError, { ErrorDisplay, ErrorWrapper, handleAppError, + SentryErrorFallback, } from 'app/global-error' // Mocks @@ -221,3 +222,39 @@ describe('ErrorWrapper component', () => { shouldThrowError = false }) }) + +describe('SentryErrorFallback component', () => { + beforeEach(() => { + jest.clearAllMocks() + }) + + test('captures exception and renders default 500 error display', () => { + const error = new Error('Boundary caught error') + render() + + expect(Sentry.captureException).toHaveBeenCalledWith(error) + expect(screen.getByText('500')).toBeInTheDocument() + expect(screen.getByText('Server Error')).toBeInTheDocument() + }) + + test('uses custom errorConfig when provided', () => { + const error = new Error('Custom error') + const customConfig = { statusCode: 404, title: 'Custom Not Found', message: 'Custom message' } + render() + + expect(Sentry.captureException).toHaveBeenCalledWith(error) + expect(screen.getByText('404')).toBeInTheDocument() + expect(screen.getByText('Custom Not Found')).toBeInTheDocument() + expect(screen.getByText('Custom message')).toBeInTheDocument() + }) + + test('wraps non-Error value in Error before capturing', () => { + const nonError = 'string error' + render() + + expect(Sentry.captureException).toHaveBeenCalledTimes(1) + const captured = (Sentry.captureException as jest.Mock).mock.calls[0][0] + expect(captured).toBeInstanceOf(Error) + expect(captured.message).toBe('string error') + }) +}) diff --git a/frontend/__tests__/unit/pages/BoardCandidatesPage.test.tsx b/frontend/__tests__/unit/pages/BoardCandidatesPage.test.tsx new file mode 100644 index 0000000000..cbc9421ee7 --- /dev/null +++ b/frontend/__tests__/unit/pages/BoardCandidatesPage.test.tsx @@ -0,0 +1,195 @@ +import { useQuery, useApolloClient } from '@apollo/client/react' +import { screen, waitFor } from '@testing-library/react' +import { render } from 'wrappers/testUtil' +import BoardCandidatesPage from 'app/board/[year]/candidates/page' +import { + GetBoardCandidatesDocument, + GetMemberSnapshotDocument, +} from 'types/__generated__/boardQueries.generated' + +jest.mock('@apollo/client/react', () => ({ + ...jest.requireActual('@apollo/client/react'), + useQuery: jest.fn(), + useApolloClient: jest.fn(), +})) + +jest.mock('next/navigation', () => ({ + useParams: jest.fn(() => ({ year: '2025' })), +})) + +jest.mock('app/global-error', () => ({ + handleAppError: jest.fn(), + ErrorDisplay: ({ title, message }: { title: string; message: string }) => ( +
+ {title} + {message} +
+ ), +})) + +jest.mock('next/image', () => ({ + __esModule: true, + // Mock uses img for simplicity in tests; next/image is not used in test DOM. + default: ({ src, alt }: { src: string; alt: string }) => ( + // eslint-disable-next-line @next/next/no-img-element -- test mock + {alt} + ), +})) + +jest.mock('components/ContributionHeatmap', () => ({ + __esModule: true, + default: () =>
, +})) + +const mockBoardData = { + boardOfDirectors: { + id: 'board-1', + year: 2025, + owaspUrl: 'https://owasp.org', + candidates: [ + { + id: 'candidate-1', + memberName: 'Alice Smith', + memberEmail: 'alice@example.com', + description: 'Platform and security experience.', + member: { + id: 'user-1', + login: 'alice', + name: 'Alice Smith', + avatarUrl: 'https://example.com/avatar.png', + bio: 'Security lead.', + createdAt: 1609459200, + firstOwaspContributionAt: 1609459200, + isOwaspBoardMember: false, + isFormerOwaspStaff: false, + isGsocMentor: false, + linkedinPageId: '', + }, + }, + ], + }, +} + +const mockUseQuery = useQuery as unknown as jest.Mock +const mockUseApolloClient = useApolloClient as unknown as jest.Mock + +describe('BoardCandidatesPage', () => { + beforeEach(() => { + mockUseApolloClient.mockReturnValue({ + query: jest.fn().mockResolvedValue({ data: {} }), + }) + mockUseQuery.mockImplementation( + (document: unknown, _options?: { variables?: Record }) => { + if (document === GetBoardCandidatesDocument) { + return { + data: mockBoardData, + loading: false, + error: null, + } + } + if (document === GetMemberSnapshotDocument) { + return { + data: { memberSnapshot: null }, + loading: false, + error: null, + } + } + return { data: null, loading: false, error: null } + } + ) + }) + + afterEach(() => { + jest.clearAllMocks() + }) + + test('renders loading state when query is loading', () => { + mockUseQuery.mockImplementation((document: unknown) => { + if (document === GetBoardCandidatesDocument) { + return { data: null, loading: true, error: null } + } + return { data: { memberSnapshot: null }, loading: false, error: null } + }) + + render() + + const loadingIndicators = screen.getAllByAltText('Loading indicator') + expect(loadingIndicators.length).toBeGreaterThan(0) + }) + + test('renders 404 when board data is missing', async () => { + mockUseQuery.mockImplementation((document: unknown) => { + if (document === GetBoardCandidatesDocument) { + return { data: null, loading: false, error: null } + } + return { data: { memberSnapshot: null }, loading: false, error: null } + }) + + render() + + await waitFor(() => { + expect(screen.getByTestId('error-display')).toBeInTheDocument() + }) + expect(screen.getByTestId('error-title')).toHaveTextContent('Board not found') + }) + + test('renders page title and year when board exists', async () => { + render() + + await waitFor(() => { + expect(screen.getByRole('heading', { level: 1 })).toHaveTextContent( + '2025 Board of Directors Candidates' + ) + }) + }) + + test('renders empty state when no candidates', async () => { + mockUseQuery.mockImplementation((document: unknown) => { + if (document === GetBoardCandidatesDocument) { + return { + data: { + boardOfDirectors: { + ...mockBoardData.boardOfDirectors, + candidates: [], + }, + }, + loading: false, + error: null, + } + } + return { data: { memberSnapshot: null }, loading: false, error: null } + }) + + render() + + await waitFor(() => { + expect(screen.getByText(/No candidates found for 2025/)).toBeInTheDocument() + }) + }) + + test('renders candidate cards when candidates exist', async () => { + render() + + await waitFor(() => { + expect(screen.getByText('Alice Smith')).toBeInTheDocument() + }) + expect(screen.getByText(/Platform and security experience/)).toBeInTheDocument() + }) + + test('calls handleAppError when board query errors', async () => { + const { handleAppError } = await import('app/global-error') + const graphQLError = new Error('GraphQL error') + mockUseQuery.mockImplementation((document: unknown) => { + if (document === GetBoardCandidatesDocument) { + return { data: null, loading: false, error: graphQLError } + } + return { data: { memberSnapshot: null }, loading: false, error: null } + }) + + render() + + await waitFor(() => { + expect(handleAppError).toHaveBeenCalledWith(graphQLError) + }) + }) +}) diff --git a/frontend/__tests__/unit/pages/UserDetails.test.tsx b/frontend/__tests__/unit/pages/UserDetails.test.tsx index fa5cda726a..d9a512d963 100644 --- a/frontend/__tests__/unit/pages/UserDetails.test.tsx +++ b/frontend/__tests__/unit/pages/UserDetails.test.tsx @@ -4,7 +4,7 @@ import { mockUserDetailsData } from '@mockData/mockUserDetails' import { screen, waitFor } from '@testing-library/react' import { render } from 'wrappers/testUtil' import '@testing-library/jest-dom' -import UserDetailsPage from 'app/members/[memberKey]/page' +import UserDetailsPage, { UserSummary } from 'app/members/[memberKey]/page' // Mock Apollo Client jest.mock('@apollo/client/react', () => ({ @@ -65,6 +65,104 @@ jest.mock('@heroui/toast', () => ({ addToast: jest.fn(), })) +describe('UserSummary', () => { + test('renders user avatar, login link, and formatted bio', () => { + const user = { + login: 'johndoe', + name: 'John Doe', + avatarUrl: 'https://example.com/avatar.png', + url: 'https://github.com/johndoe', + } + render( + Bio text} + /> + ) + expect(screen.getByRole('img', { name: 'John Doe' })).toBeInTheDocument() + expect(screen.getByRole('link', { name: '@johndoe' })).toHaveAttribute( + 'href', + 'https://github.com/johndoe' + ) + expect(screen.getByText('Bio text')).toBeInTheDocument() + }) + + test('renders contribution heatmap when hasContributionData is true', () => { + const user = { + login: 'jane', + name: 'Jane', + avatarUrl: '/avatar.png', + url: 'https://github.com/jane', + } + render( + + ) + expect(screen.getByTestId('contribution-heatmap')).toBeInTheDocument() + }) + + test('uses login as avatar alt when user has no name', () => { + const user = { + login: 'nologin', + name: undefined, + avatarUrl: '/avatar.png', + url: 'https://github.com/nologin', + } + render( + + ) + expect(screen.getByRole('img', { name: 'nologin' })).toBeInTheDocument() + }) + + test('uses placeholder avatar and fallback alt when user is null', () => { + render( + + ) + const img = screen.getByRole('img', { name: 'User Avatar' }) + expect(img).toHaveAttribute('src', expect.stringContaining('placeholder.svg')) + }) + + test('renders badges when user has badges', () => { + const user = { + login: 'badged', + name: 'Badged User', + avatarUrl: '/a.png', + url: 'https://github.com/badged', + badges: [{ id: 'b1', name: 'Star', cssClass: 'fa-star', description: 'Star', weight: 1 }], + } + render( + + ) + expect(screen.getByTestId('badge-star')).toBeInTheDocument() + }) +}) + describe('UserDetailsPage', () => { beforeEach(() => { ;(useQuery as unknown as jest.Mock).mockReturnValue({ diff --git a/frontend/src/app/board/[year]/candidates/page.tsx b/frontend/src/app/board/[year]/candidates/page.tsx index 2820c5933e..85e4992adf 100644 --- a/frontend/src/app/board/[year]/candidates/page.tsx +++ b/frontend/src/app/board/[year]/candidates/page.tsx @@ -24,6 +24,12 @@ import LoadingSpinner from 'components/LoadingSpinner' dayjs.extend(relativeTime) +const sortByName = (items: T[]): T[] => + [...items].sort((a, b) => a.name.localeCompare(b.name)) + +const sortByCount = (entries: Array<[string, number]>): Array<[string, number]> => + [...entries].sort(([, a], [, b]) => b - a) + type Candidate = { id: string memberName: string @@ -85,602 +91,581 @@ type CandidateWithSnapshot = Candidate & { snapshot?: MemberSnapshot } -const BoardCandidatesPage = () => { - const { year } = useParams<{ year: string }>() - const [candidates, setCandidates] = useState([]) - - const { - data: graphQLData, - error: graphQLRequestError, - loading, - } = useQuery(GetBoardCandidatesDocument, { - variables: { year: Number.parseInt(year) }, - }) - - useEffect(() => { - if (graphQLData?.boardOfDirectors) { - setCandidates(graphQLData.boardOfDirectors.candidates || []) - } - if (graphQLRequestError) { - handleAppError(graphQLRequestError) - } - }, [graphQLData, graphQLRequestError]) - - const CandidateCard = ({ candidate }: { candidate: CandidateWithSnapshot }) => { - const client = useApolloClient() - const [snapshot, setSnapshot] = useState(null) - const [ledChapters, setLedChapters] = useState([]) - const [ledProjects, setLedProjects] = useState([]) - - const sortByName = (items: T[]): T[] => { - return [...items].sort((a, b) => a.name.localeCompare(b.name)) - } - - const sortByContributionCount = (entries: Array<[string, number]>): Array<[string, number]> => { - return [...entries].sort(([, a], [, b]) => b - a) - } +interface CandidateCardProps { + candidate: CandidateWithSnapshot + year: string +} - const sortChannelsByMessageCount = ( - entries: Array<[string, number]> - ): Array<[string, number]> => { - return [...entries].sort(([, a], [, b]) => b - a) - } +const CandidateCard = ({ candidate, year }: CandidateCardProps) => { + const client = useApolloClient() + const [ledChapters, setLedChapters] = useState([]) + const [ledProjects, setLedProjects] = useState([]) + + // Render a single channel link item + const renderChannelLink = (channelName: string, messageCount: string | number) => ( + e.stopPropagation()} + > + #{channelName} + + {Number(messageCount)} messages + + + ) - // Render a single repository link item - const renderChannelLink = (channelName: string, messageCount: string | number) => ( + // Render a single repository link item + const renderRepositoryLink = (repoName: string, count: number) => { + const commitCount = Number(count) + return ( e.stopPropagation()} > - #{channelName} + {repoName} - {Number(messageCount)} messages + {commitCount} commits ) + } - // Render a single repository link item - const renderRepositoryLink = (repoName: string, count: number) => { - const commitCount = Number(count) - return ( - e.stopPropagation()} - > - {repoName} - - {commitCount} commits - - - ) - } - - const renderTopActiveChannels = () => { - if (!snapshot) return null - - if ( - !snapshot.channelCommunications || - Object.keys(snapshot.channelCommunications).length <= 0 - ) { - return ( -
-

- Top 5 Active Channels -

-
- No Engagement -
-
- ) - } - - const sortedChannels = sortChannelsByMessageCount( - Object.entries(snapshot.channelCommunications) - ) - - if (sortedChannels.length === 0) return null - - const topChannel = sortedChannels[0] - const [topChannelName, topChannelCount] = topChannel + const renderTopActiveChannels = () => { + if (!snapshot) return null + if ( + !snapshot.channelCommunications || + Object.keys(snapshot.channelCommunications).length <= 0 + ) { return (
-
-

- Top 5 Active Channels -

- e.stopPropagation()} - > - #{topChannelName} - - {Number(topChannelCount)} messages - - +

+ Top 5 Active Channels +

+
+ No Engagement
- {sortedChannels.length > 1 && ( -
-
- {sortedChannels - .slice(1) - .map(([channelName, messageCount]) => - renderChannelLink(channelName, messageCount) - )} -
-
- )}
) } - const { data: snapshotData } = useQuery(GetMemberSnapshotDocument, { - variables: { - userLogin: candidate.member?.login || '', - }, - skip: !candidate.member?.login, - }) + const sortedChannels = sortByCount(Object.entries(snapshot.channelCommunications)) - useEffect(() => { - if (snapshotData?.memberSnapshot) { - setSnapshot(snapshotData.memberSnapshot) - } - }, [snapshotData]) + if (sortedChannels.length === 0) return null - // Fetch chapters based on chapterContributions keys - useEffect(() => { - const fetchChapters = async () => { - if (!snapshot?.chapterContributions) return + const topChannel = sortedChannels[0] + const [topChannelName, topChannelCount] = topChannel - const chapterKeys = Object.keys(snapshot.chapterContributions) - const chapters: Chapter[] = [] + return ( +
+ + {sortedChannels.length > 1 && ( +
+
+ {sortedChannels + .slice(1) + .map(([channelName, messageCount]) => renderChannelLink(channelName, messageCount))} +
+
+ )} +
+ ) + } + + const { data: snapshotData } = useQuery(GetMemberSnapshotDocument, { + variables: { + userLogin: candidate.member?.login || '', + }, + skip: !candidate.member?.login, + }) + // Derive snapshot directly from the query result to avoid an extra render + const snapshot: MemberSnapshot | null = snapshotData?.memberSnapshot ?? null - for (const key of chapterKeys) { - try { - const { data } = await client.query({ - query: GetChapterByKeyDocument, - variables: { key: key.replace('www-chapter-', '') }, - }) + // Fetch chapters based on chapterContributions keys + useEffect(() => { + const fetchChapters = async () => { + if (!snapshot?.chapterContributions) return - if (data?.chapter) { - chapters.push(data.chapter) - } - } catch { - // Silently skip chapters that fail to fetch + const chapterKeys = Object.keys(snapshot.chapterContributions) + const chapters: Chapter[] = [] + + for (const key of chapterKeys) { + try { + const { data } = await client.query({ + query: GetChapterByKeyDocument, + variables: { key: key.replace('www-chapter-', '') }, + }) + + if (data?.chapter) { + chapters.push(data.chapter) } + } catch { + // Silently skip chapters that fail to fetch } - - setLedChapters(sortByName(chapters)) } - fetchChapters() - }, [client, snapshot?.chapterContributions]) + setLedChapters(sortByName(chapters)) + } - // Fetch projects based on projectContributions keys - useEffect(() => { - const fetchProjects = async () => { - if (!snapshot?.projectContributions) return + fetchChapters() + }, [client, snapshot?.chapterContributions]) - const projectKeys = Object.keys(snapshot.projectContributions) - const projects: Project[] = [] + // Fetch projects based on projectContributions keys + useEffect(() => { + const fetchProjects = async () => { + if (!snapshot?.projectContributions) return - for (const key of projectKeys) { - try { - const { data } = await client.query({ - query: GetProjectByKeyDocument, - variables: { key: key.replace('www-project-', '') }, - }) + const projectKeys = Object.keys(snapshot.projectContributions) + const projects: Project[] = [] - if (data?.project) { - projects.push(data.project) - } - } catch { - // Silently skip projects that fail to fetch + for (const key of projectKeys) { + try { + const { data } = await client.query({ + query: GetProjectByKeyDocument, + variables: { key: key.replace('www-project-', '') }, + }) + + if (data?.project) { + projects.push(data.project) } + } catch { + // Silently skip projects that fail to fetch } - - setLedProjects(sortByName(projects)) } - fetchProjects() - }, [client, snapshot?.projectContributions]) - - const handleCardClick = () => { - // Convert name to slug format. - const nameSlug = candidate.memberName.toLowerCase().replaceAll(/\s+/g, '_') - const candidateUrl = `https://owasp.org/www-board-candidates/${year}/${nameSlug}.html` - window.open(candidateUrl, '_blank', 'noopener,noreferrer') + setLedProjects(sortByName(projects)) } - // Check if candidate leads any flagship level projects - const leadsFlagshipProject = ledProjects.some((project) => project.level === 'flagship') + fetchProjects() + }, [client, snapshot?.projectContributions]) - return ( -
- {candidate.description && ( -

- {candidate.description} -

+
+ {candidate.member?.bio && ( +
+ {candidate.member.bio.replaceAll(/\n+/g, ' ').replaceAll(/\s+/g, ' ').trim()} +
)} +
- {snapshot && ( -
-

- OWASP Ecosystem Contributions -

-
-
- -
-

Commits

-

- {millify(snapshot.commitsCount, { precision: 1 })} -

-
+ {candidate.description && ( +

+ {candidate.description} +

+ )} + + {snapshot && ( +
+

+ OWASP Ecosystem Contributions +

+
+
+ +
+

Commits

+

+ {millify(snapshot.commitsCount, { precision: 1 })} +

-
- -
-

PRs

-

- {millify(snapshot.pullRequestsCount, { precision: 1 })} -

-
+
+
+ +
+

PRs

+

+ {millify(snapshot.pullRequestsCount, { precision: 1 })} +

-
- -
-

Issues

-

- {millify(snapshot.issuesCount, { precision: 1 })} -

-
+
+
+ +
+

Issues

+

+ {millify(snapshot.issuesCount, { precision: 1 })} +

-
- -
-

Total

-

- {millify(snapshot.totalContributions, { precision: 1 })} -

-
+
+
+ +
+

Total

+

+ {millify(snapshot.totalContributions, { precision: 1 })} +

- {snapshot.contributionHeatmapData && - Object.keys(snapshot.contributionHeatmapData).length > 0 && ( -
- -
+
+ {snapshot.contributionHeatmapData && + Object.keys(snapshot.contributionHeatmapData).length > 0 && ( +
+ +
+ )} +
+

+ Leadership + {(ledChapters.length > 0 || ledProjects.length > 0) && ( + + {' '} + ( + {ledChapters.length > 0 && ( + <> + {ledChapters.length} chapter{ledChapters.length === 1 ? '' : 's'} + + )} + {ledChapters.length > 0 && ledProjects.length > 0 && ', '} + {ledProjects.length > 0 && ( + <> + {ledProjects.length} project{ledProjects.length === 1 ? '' : 's'} + + )} + ) + )} -
-

- Leadership - {(ledChapters.length > 0 || ledProjects.length > 0) && ( - - {' '} - ( - {ledChapters.length > 0 && ( - <> - {ledChapters.length} chapter{ledChapters.length === 1 ? '' : 's'} - - )} - {ledChapters.length > 0 && ledProjects.length > 0 && ', '} - {ledProjects.length > 0 && ( - <> - {ledProjects.length} project{ledProjects.length === 1 ? '' : 's'} - - )} - ) - - )} -

- {ledChapters.length === 0 && ledProjects.length === 0 ? ( -
- No chapters or projects are lead by this candidate -
- ) : ( -
- {ledChapters.map((chapter) => { - // Strip prefix if present to get bare key - const bareKey = chapter.key.startsWith('www-chapter-') - ? chapter.key.replace('www-chapter-', '') - : chapter.key - // Check both key formats for compatibility - const directKey = snapshot?.chapterContributions?.[bareKey] - const withPrefix = snapshot?.chapterContributions?.[`www-chapter-${bareKey}`] - const contributionCount = directKey || withPrefix || 0 - - return ( - + {ledChapters.length === 0 && ledProjects.length === 0 ? ( +
+ No chapters or projects are led by this candidate +
+ ) : ( +
+ {ledChapters.map((chapter) => { + // Strip prefix if present to get bare key + const bareKey = chapter.key.startsWith('www-chapter-') + ? chapter.key.replace('www-chapter-', '') + : chapter.key + // Check both key formats for compatibility + const directKey = snapshot?.chapterContributions?.[bareKey] + const withPrefix = snapshot?.chapterContributions?.[`www-chapter-${bareKey}`] + const contributionCount = directKey || withPrefix || 0 + + return ( + e.stopPropagation()} + > + {chapter.name} + e.stopPropagation()} > - {chapter.name} - - {contributionCount === 0 - ? 'no contributions' - : `${contributionCount} contributions`} - - - ) - })} - {ledProjects.map((project) => { - // Strip prefix if present to get bare key - const bareKey = project.key.startsWith('www-project-') - ? project.key.replace('www-project-', '') - : project.key - // Check both key formats for compatibility - const directKey = snapshot?.projectContributions?.[bareKey] - const withPrefix = snapshot?.projectContributions?.[`www-project-${bareKey}`] - const contributionCount = directKey || withPrefix || 0 - return ( - + + ) + })} + {ledProjects.map((project) => { + // Strip prefix if present to get bare key + const bareKey = project.key.startsWith('www-project-') + ? project.key.replace('www-project-', '') + : project.key + // Check both key formats for compatibility + const directKey = snapshot?.projectContributions?.[bareKey] + const withPrefix = snapshot?.projectContributions?.[`www-project-${bareKey}`] + const contributionCount = directKey || withPrefix || 0 + return ( + e.stopPropagation()} + > + {project.name} + e.stopPropagation()} > - {project.name} - - {contributionCount === 0 - ? 'no contributions' - : `${contributionCount} contributions`} - - - ) - })} -
- )} -
- {snapshot.repositoryContributions && - Object.keys(snapshot.repositoryContributions).length > 0 && - (() => { - const sortedRepos = sortByContributionCount( - Object.entries(snapshot.repositoryContributions) - ) - const topRepo = sortedRepos[0] - const [topRepoName, topRepoCount] = topRepo - - return ( -
- - {sortedRepos.length > 1 && ( -
-
- {sortedRepos - .slice(1) - .map(([repoName, count]) => renderRepositoryLink(repoName, count))} -
-
- )} + {contributionCount === 0 + ? 'no contributions' + : `${contributionCount} contributions`} + + + ) + })} +
+ )} +
+ {snapshot.repositoryContributions && + Object.keys(snapshot.repositoryContributions).length > 0 && + (() => { + const sortedRepos = sortByCount(Object.entries(snapshot.repositoryContributions)) + const topRepo = sortedRepos[0] + const [topRepoName, topRepoCount] = topRepo + + return ( +
+ - ) - })()} + {sortedRepos.length > 1 && ( +
+
+ {sortedRepos + .slice(1, 5) + .map(([repoName, count]) => renderRepositoryLink(repoName, count))} +
+
+ )} +
+ ) + })()} +

+ )} + + {/* Slack Communications Heatmap */} + {snapshot?.communicationHeatmapData && + Object.keys(snapshot.communicationHeatmapData).length > 0 && ( +
+
)} - {/* Slack Communications Heatmap */} - {snapshot?.communicationHeatmapData && - Object.keys(snapshot.communicationHeatmapData).length > 0 && ( -
- + {renderTopActiveChannels()} + + {/* Additional Information */} + {(candidate.member?.isOwaspBoardMember || + candidate.member?.isFormerOwaspStaff || + candidate.member?.isGsocMentor || + leadsFlagshipProject) && ( +
+

+ Additional Information +

+
+ {candidate.member?.isOwaspBoardMember && ( + + OWASP Board of Directors Member + + )} + {candidate.member?.isFormerOwaspStaff && ( + + Former OWASP Staff Member + + )} + {candidate.member?.isGsocMentor && ( + + Google Summer of Code Mentor + + )} +
+ {leadsFlagshipProject && ( +
+ This candidate may have additional community engagement in other Slack workspaces + based on the flagship level project(s) they are leading.
)} +
+ )} + + ) +} + +const BoardCandidatesPage = () => { + const { year } = useParams<{ year: string }>() + const { + data: graphQLData, + error: graphQLRequestError, + loading, + } = useQuery(GetBoardCandidatesDocument, { + variables: { year: Number.parseInt(year) }, + }) - {renderTopActiveChannels()} + // Derive candidates directly from GraphQL data to avoid an extra render + const candidates: CandidateWithSnapshot[] = graphQLData?.boardOfDirectors?.candidates ?? [] - {/* Additional Information */} - {(candidate.member?.isOwaspBoardMember || - candidate.member?.isFormerOwaspStaff || - candidate.member?.isGsocMentor || - leadsFlagshipProject) && ( -
-

- Additional Information -

-
- {candidate.member?.isOwaspBoardMember && ( - - OWASP Board of Directors Member - - )} - {candidate.member?.isFormerOwaspStaff && ( - - Former OWASP Staff Member - - )} - {candidate.member?.isGsocMentor && ( - - Google Summer of Code Mentor - - )} -
- {leadsFlagshipProject && ( -
- This candidate may have additional community engagement in other Slack workspaces - based on the flagship level project(s) they are leading. -
- )} -
- )} - - ) - } + // Keep reporting errors as a side-effect only + useEffect(() => { + if (graphQLRequestError) { + handleAppError(graphQLRequestError) + } + }, [graphQLRequestError]) if (loading) { return @@ -752,7 +737,7 @@ const BoardCandidatesPage = () => { ) : (
{candidates.map((candidate) => ( - + ))}
)} diff --git a/frontend/src/app/global-error.tsx b/frontend/src/app/global-error.tsx index 6b0c5442b9..9532023d39 100644 --- a/frontend/src/app/global-error.tsx +++ b/frontend/src/app/global-error.tsx @@ -91,19 +91,29 @@ export const handleAppError = (error: unknown) => { export const ErrorWrapper: React.FC<{ children: React.ReactNode }> = ({ children }) => { return ( { - Sentry.captureException(error) - const errorConfig = ERROR_CONFIGS['500'] - return - }} + fallback={(props) => } > {children} ) } +export const SentryErrorFallback: React.FC<{ + error: unknown + errorConfig?: ErrorDisplayProps +}> = ({ error, errorConfig = ERROR_CONFIGS['500'] }) => { + React.useEffect(() => { + Sentry.captureException(error instanceof Error ? error : new Error(String(error))) + }, [error]) + + return +} + export default function GlobalError({ error }: Readonly<{ error: Error }>) { - Sentry.captureException(error) + React.useEffect(() => { + Sentry.captureException(error) + }, [error]) + const errorConfig = ERROR_CONFIGS['500'] return } diff --git a/frontend/src/app/members/[memberKey]/page.tsx b/frontend/src/app/members/[memberKey]/page.tsx index 962008aed4..dabcbeca37 100644 --- a/frontend/src/app/members/[memberKey]/page.tsx +++ b/frontend/src/app/members/[memberKey]/page.tsx @@ -9,12 +9,76 @@ import { handleAppError, ErrorDisplay } from 'app/global-error' import { GetUserDataDocument } from 'types/__generated__/userQueries.generated' import { Badge } from 'types/badge' +import { User } from 'types/user' import { formatDate } from 'utils/dateFormatter' import Badges from 'components/Badges' import DetailsCard from 'components/CardDetailsPage' import ContributionHeatmap from 'components/ContributionHeatmap' import MemberDetailsPageSkeleton from 'components/skeletons/MemberDetailsPageSkeleton' +type DateRange = { startDate: string; endDate: string } + +interface UserSummaryProps { + user: User | null + contributionData: Record + dateRange: DateRange + hasContributionData: boolean + formattedBio: React.ReactNode +} + +export const UserSummary: React.FC = ({ + user, + contributionData, + dateRange, + hasContributionData, + formattedBio, +}) => ( +
+ {user?.name +
+
+
+ + @{user?.login} + + {user?.badges && user.badges.length > 0 && ( +
+ {user.badges.slice().map((badge: Badge) => ( + + + + ))} +
+ )} +
+

{formattedBio}

+
+ {hasContributionData && dateRange.startDate && dateRange.endDate && ( +
+
+ +
+
+ )} +
+
+) + const UserDetailsPage: React.FC = () => { const { memberKey } = useParams<{ memberKey: string }>() @@ -129,56 +193,6 @@ const UserDetailsPage: React.FC = () => { { icon: FaCodeMerge, value: user?.contributionsCount || 0, unit: 'Contribution' }, ] - const UserSummary = () => ( -
- {user?.name -
-
-
- - @{user?.login} - - {user?.badges && user.badges.length > 0 && ( -
- {user.badges.slice().map((badge: Badge) => ( - - - - ))} -
- )} -
-

{formattedBio}

-
- {hasContributionData && dateRange.startDate && dateRange.endDate && ( -
-
- -
-
- )} -
-
- ) - return ( { stats={userStats} title={user?.name || user?.login} type="user" - userSummary={} + userSummary={ + + } /> ) } diff --git a/frontend/src/components/SkeletonsBase.tsx b/frontend/src/components/SkeletonsBase.tsx index 96577819bd..1519b5943f 100644 --- a/frontend/src/components/SkeletonsBase.tsx +++ b/frontend/src/components/SkeletonsBase.tsx @@ -1,4 +1,5 @@ import { Skeleton } from '@heroui/skeleton' +import type { CardSkeletonProps } from 'types/skeleton' import LoadingSpinner from 'components/LoadingSpinner' import AboutSkeleton from 'components/skeletons/AboutSkeleton' import CardSkeleton from 'components/skeletons/Card' @@ -6,6 +7,8 @@ import MemberDetailsPageSkeleton from 'components/skeletons/MemberDetailsPageSke import OrganizationDetailsPageSkeleton from 'components/skeletons/OrganizationDetailsPageSkeleton' import SnapshotSkeleton from 'components/skeletons/SnapshotSkeleton' import UserCardSkeleton from 'components/skeletons/UserCard' + +// Use CardSkeleton directly; wrapper removed function userCardRender() { const cardCount = 12 return ( @@ -35,31 +38,26 @@ const SkeletonBase = ({ indexName: string loadingImageUrl: string }) => { - let Component + let componentProps: CardSkeletonProps = {} + switch (indexName) { case 'chapters': - Component = () => + componentProps = { showLevel: false, showIcons: false, showLink: false } break case 'issues': - Component = () => ( - - ) + componentProps = { + showLevel: false, + showIcons: true, + numIcons: 2, + showContributors: false, + showSocial: false, + } break case 'projects': - Component = () => ( - - ) + componentProps = { showLink: false, showSocial: false, showIcons: true, numIcons: 3 } break case 'committees': - Component = () => ( - - ) + componentProps = { showLink: false, showLevel: false, showIcons: true, numIcons: 1 } break case 'users': return userCardRender() @@ -82,11 +80,11 @@ const SkeletonBase = ({ {indexName == 'chapters' ? ( ) : ( - + )} - - - + + +
) }