diff --git a/frontend/__tests__/unit/components/IssuesTable.test.tsx b/frontend/__tests__/unit/components/IssuesTable.test.tsx new file mode 100644 index 0000000000..4b19dbed63 --- /dev/null +++ b/frontend/__tests__/unit/components/IssuesTable.test.tsx @@ -0,0 +1,400 @@ +import { render, screen, fireEvent, within } from '@testing-library/react' +import '@testing-library/jest-dom' +import React from 'react' +import IssuesTable, { type IssueRow } from 'components/IssuesTable' + +jest.mock('next/navigation', () => ({ + useRouter: () => ({ + push: jest.fn(), + }), +})) + +jest.mock('next/image', () => { + return function MockImage({ + src, + alt, + style, + fill, + }: { + src: string + alt: string + style?: React.CSSProperties + fill?: boolean + }) { + // eslint-disable-next-line @next/next/no-img-element + return {alt} + } +}) + +jest.mock('@heroui/tooltip', () => ({ + Tooltip: ({ + children, + content, + isDisabled, + }: { + children: React.ReactNode + content: string + isDisabled?: boolean + }) => { + if (isDisabled) { + return <>{children} + } + return ( +
+ {children} +
+ ) + }, +})) + +const mockIssues: IssueRow[] = [ + { + objectID: '1', + number: 123, + title: 'Test Issue 1', + state: 'open', + isMerged: false, + labels: ['bug', 'enhancement'], + assignees: [ + { + avatarUrl: 'https://example.com/avatar1.jpg', + login: 'user1', + name: 'User One', + }, + ], + url: 'https://github.com/test/repo/issues/123', + }, + { + objectID: '2', + number: 124, + title: 'Test Issue 2', + state: 'closed', + isMerged: false, + labels: ['documentation'], + assignees: [], + url: 'https://github.com/test/repo/issues/124', + }, + { + objectID: '3', + number: 125, + title: 'Test Issue 3', + state: 'closed', + isMerged: true, + labels: [], + assignees: [ + { + avatarUrl: 'https://example.com/avatar2.jpg', + login: 'user2', + name: 'User Two', + }, + { + avatarUrl: 'https://example.com/avatar3.jpg', + login: 'user3', + name: 'User Three', + }, + ], + url: 'https://github.com/test/repo/issues/125', + }, +] + +describe('', () => { + const defaultProps = { + issues: mockIssues, + } + + describe('Rendering', () => { + it('renders table view', () => { + render() + const table = screen.getByRole('table') + expect(table).toBeInTheDocument() + }) + + it('renders all issue rows', () => { + render() + const issue1Buttons = screen.getAllByRole('button', { name: /Test Issue 1/i }) + const issue2Buttons = screen.getAllByRole('button', { name: /Test Issue 2/i }) + const issue3Buttons = screen.getAllByRole('button', { name: /Test Issue 3/i }) + expect(issue1Buttons.length).toBeGreaterThan(0) + expect(issue2Buttons.length).toBeGreaterThan(0) + expect(issue3Buttons.length).toBeGreaterThan(0) + }) + + it('renders table headers correctly', () => { + render() + expect(screen.getByText('Title')).toBeInTheDocument() + expect(screen.getByText('Status')).toBeInTheDocument() + expect(screen.getByText('Labels')).toBeInTheDocument() + expect(screen.getByText('Assignee')).toBeInTheDocument() + }) + }) + + describe('Status Badge', () => { + it('renders Open status badge correctly', () => { + render() + const openBadges = screen.getAllByText('Open') + expect(openBadges.length).toBeGreaterThan(0) + }) + + it('renders Closed status badge correctly', () => { + render() + const closedBadges = screen.getAllByText('Closed') + expect(closedBadges.length).toBeGreaterThan(0) + }) + + it('renders Merged status badge when isMerged is true', () => { + render() + const mergedBadges = screen.getAllByText('Merged') + expect(mergedBadges.length).toBeGreaterThan(0) + }) + + it('defaults to Closed status for unknown states', () => { + const unknownIssue: IssueRow = { + objectID: '4', + number: 126, + title: 'Unknown State Issue', + state: 'unknown', + labels: [], + } + render() + const closedBadges = screen.getAllByText('Closed') + expect(closedBadges.length).toBeGreaterThan(0) + }) + }) + + describe('Labels', () => { + it('renders labels correctly', () => { + render() + const bugLabels = screen.getAllByText('bug') + const enhancementLabels = screen.getAllByText('enhancement') + expect(bugLabels.length).toBeGreaterThan(0) + expect(enhancementLabels.length).toBeGreaterThan(0) + }) + + it('renders "+X more" when labels exceed maxVisibleLabels', () => { + const manyLabelsIssue: IssueRow = { + objectID: '5', + number: 127, + title: 'Many Labels Issue', + state: 'open', + labels: ['label1', 'label2', 'label3', 'label4', 'label5', 'label6', 'label7'], + } + render() + expect(screen.getByText('+2 more')).toBeInTheDocument() + }) + + it('does not render labels section when labels array is empty', () => { + render() + const issueTitles = screen.getAllByText('Test Issue 3') + const labelsCell = issueTitles[0].closest('tr')?.querySelector('td:nth-child(3)') + expect(labelsCell?.textContent).toBe('') + }) + + it('respects maxVisibleLabels prop', () => { + const manyLabelsIssue: IssueRow = { + objectID: '6', + number: 128, + title: 'Many Labels Issue', + state: 'open', + labels: ['label1', 'label2', 'label3', 'label4', 'label5'], + } + render() + expect(screen.getByText('+2 more')).toBeInTheDocument() + }) + }) + + describe('Assignee Column', () => { + it('renders assignee column when showAssignee is true (default)', () => { + render() + expect(screen.getByText('Assignee')).toBeInTheDocument() + }) + + it('hides assignee column when showAssignee is false', () => { + render() + expect(screen.queryByText('Assignee')).not.toBeInTheDocument() + }) + + it('renders assignee avatar and name', () => { + render() + const assigneeImages = screen.getAllByAltText('user1') + const user1Texts = screen.getAllByText('user1') + expect(assigneeImages.length).toBeGreaterThan(0) + expect(user1Texts.length).toBeGreaterThan(0) + }) + + it('shows "+X" indicator when multiple assignees', () => { + render() + const plusOneElements = screen.getAllByText(/\+1/) + expect(plusOneElements.length).toBeGreaterThan(0) + }) + + it('does not render assignee section when no assignees', () => { + render() + const issueTitles = screen.getAllByText('Test Issue 2') + const assigneeCell = issueTitles[0].closest('tr')?.querySelector('td:nth-child(4)') + expect(assigneeCell?.textContent).toBe('') + }) + + it('uses login when name is not available', () => { + const issueWithoutName: IssueRow = { + objectID: '7', + number: 129, + title: 'Issue Without Name', + state: 'open', + labels: [], + assignees: [ + { + avatarUrl: 'https://example.com/avatar4.jpg', + login: 'user4', + name: '', + }, + ], + } + render() + const user4Texts = screen.getAllByText('user4') + expect(user4Texts.length).toBeGreaterThan(0) + }) + }) + + describe('Click Handlers', () => { + it('calls onIssueClick when provided', () => { + const onIssueClick = jest.fn() + render() + const issueButtons = screen.getAllByRole('button', { name: /Test Issue 1/i }) + expect(issueButtons.length).toBeGreaterThan(0) + fireEvent.click(issueButtons[0]) + expect(onIssueClick).toHaveBeenCalledWith(123) + }) + }) + + describe('Empty State', () => { + it('renders empty message when no issues', () => { + render() + const emptyMessages = screen.getAllByText('No issues found.') + expect(emptyMessages.length).toBeGreaterThan(0) + }) + + it('renders custom empty message', () => { + const customMessage = 'No issues found for the selected filter.' + render() + const customMessages = screen.getAllByText(customMessage) + expect(customMessages.length).toBeGreaterThan(0) + }) + + it('renders empty state in desktop table', () => { + render() + const table = screen.getByRole('table') + const emptyRow = within(table).getByText('No issues found.') + expect(emptyRow).toBeInTheDocument() + }) + + it('renders empty state in mobile view', () => { + render() + const emptyMessages = screen.getAllByText('No issues found.') + expect(emptyMessages.length).toBeGreaterThan(0) + }) + }) + + describe('Tooltip', () => { + it('shows tooltip for long titles', () => { + const longTitleIssue: IssueRow = { + objectID: '8', + number: 130, + title: 'This is a very long issue title that exceeds fifty characters in length', + state: 'open', + labels: [], + } + render() + const tooltips = screen.getAllByTestId('tooltip') + const longTitleTooltip = tooltips.find( + (tooltip) => tooltip.dataset.content === longTitleIssue.title + ) + expect(longTitleTooltip).toBeInTheDocument() + expect(longTitleTooltip?.dataset.content).toBe(longTitleIssue.title) + }) + + it('disables tooltip for short titles', () => { + render() + const buttons = screen.getAllByRole('button', { name: /Test Issue 1/i }) + expect(buttons.length).toBeGreaterThan(0) + const tooltips = screen.queryAllByTestId('tooltip') + const shortTitleTooltip = tooltips.find( + (tooltip) => tooltip.dataset.content === mockIssues[0].title + ) + expect(shortTitleTooltip).toBeUndefined() + }) + }) + + describe('Mobile View', () => { + it('renders status badge in mobile view', () => { + render() + const openBadges = screen.getAllByText('Open') + expect(openBadges.length).toBeGreaterThan(0) + }) + + it('renders labels in mobile view (limited to 3)', () => { + const manyLabelsIssue: IssueRow = { + objectID: '9', + number: 131, + title: 'Many Labels Issue', + state: 'open', + labels: ['label1', 'label2', 'label3', 'label4', 'label5'], + } + render() + const label1Elements = screen.getAllByText('label1') + expect(label1Elements.length).toBeGreaterThan(0) + }) + + it('renders assignee in mobile view when showAssignee is true', () => { + render() + const user1Elements = screen.getAllByText('user1') + expect(user1Elements.length).toBeGreaterThan(0) + }) + + it('does not render assignee in mobile view when showAssignee is false', () => { + render() + const assigneeImages = screen.queryAllByAltText('user1') + expect(assigneeImages.length).toBe(0) + }) + }) + + describe('Column Count', () => { + it('calculates correct column count with assignee column', () => { + render() + const table = screen.getByRole('table') + const headers = within(table).getAllByRole('columnheader') + expect(headers).toHaveLength(4) + }) + + it('calculates correct column count without assignee column', () => { + render() + const table = screen.getByRole('table') + const headers = within(table).getAllByRole('columnheader') + expect(headers).toHaveLength(3) + }) + }) + + describe('Default Props', () => { + it('uses default showAssignee value (true)', () => { + render() + expect(screen.getByText('Assignee')).toBeInTheDocument() + }) + + it('uses default emptyMessage', () => { + render() + const emptyMessages = screen.getAllByText('No issues found.') + expect(emptyMessages.length).toBeGreaterThan(0) + }) + + it('uses default maxVisibleLabels (5)', () => { + const manyLabelsIssue: IssueRow = { + objectID: '10', + number: 132, + title: 'Many Labels Issue', + state: 'open', + labels: ['label1', 'label2', 'label3', 'label4', 'label5', 'label6'], + } + render() + expect(screen.getByText('+1 more')).toBeInTheDocument() + }) + }) +}) diff --git a/frontend/__tests__/unit/pages/IssuesPage.test.tsx b/frontend/__tests__/unit/pages/IssuesPage.test.tsx index b310b6d245..16d097b9d7 100644 --- a/frontend/__tests__/unit/pages/IssuesPage.test.tsx +++ b/frontend/__tests__/unit/pages/IssuesPage.test.tsx @@ -89,7 +89,7 @@ describe('IssuesPage', () => { error: undefined, }) render() - expect(screen.getAllByText('No issues found for the selected filter.')).toHaveLength(2) + expect(screen.getAllByText('No issues found for the selected filter.')).toHaveLength(1) }) it('renders the list of issues successfully', () => { @@ -258,6 +258,7 @@ describe('IssuesPage', () => { error: undefined, }) render() - expect(screen.getByText('+1')).toBeInTheDocument() + const plusOneElements = screen.getAllByText(/\+1/) + expect(plusOneElements.length).toBeGreaterThan(0) }) }) diff --git a/frontend/__tests__/unit/pages/MenteeProfilePage.test.tsx b/frontend/__tests__/unit/pages/MenteeProfilePage.test.tsx index 7d54a96067..95c7256f9d 100644 --- a/frontend/__tests__/unit/pages/MenteeProfilePage.test.tsx +++ b/frontend/__tests__/unit/pages/MenteeProfilePage.test.tsx @@ -1,6 +1,7 @@ import { useQuery } from '@apollo/client/react' import { render, screen, fireEvent, within } from '@testing-library/react' import { useParams } from 'next/navigation' +import React from 'react' import { handleAppError } from 'app/global-error' import MenteeProfilePage from 'app/my/mentorship/programs/[programKey]/modules/[moduleKey]/mentees/[menteeKey]/page' @@ -12,6 +13,9 @@ jest.mock('@apollo/client/react', () => ({ jest.mock('next/navigation', () => ({ useParams: jest.fn(), + useRouter: () => ({ + push: jest.fn(), + }), })) jest.mock('app/global-error', () => ({ @@ -34,6 +38,106 @@ jest.mock('next/image', () => ({ }, })) +jest.mock('@heroui/tooltip', () => ({ + Tooltip: ({ + children, + content, + isDisabled, + }: { + children: React.ReactNode + content: string + isDisabled?: boolean + }) => { + if (isDisabled) { + return <>{children} + } + return ( +
+ {children} +
+ ) + }, +})) + +jest.mock('@heroui/select', () => { + return { + Select: ({ + children, + selectedKeys, + onSelectionChange, + 'aria-label': ariaLabel, + classNames: _classNames, + size: _size, + ...props + }: { + children: React.ReactNode + selectedKeys: Set + onSelectionChange?: (keys: Set) => void + 'aria-label'?: string + classNames?: Record + size?: string + }) => { + const [isOpen, setIsOpen] = React.useState(false) + const selectedKey = Array.from(selectedKeys)[0] || 'all' + + const handleItemClick = (key: string) => { + if (onSelectionChange) { + onSelectionChange(new Set([key])) + } + setIsOpen(false) + } + + return ( +
+ + {isOpen && ( +
+ {React.Children.map(children, (child: React.ReactElement) => { + const itemKey = String(child.key ?? '') + return React.cloneElement(child, { + 'data-key': itemKey, + onClick: () => handleItemClick(itemKey), + } as Partial) + })} +
+ )} +
+ ) + }, + SelectItem: ({ + children, + onClick, + 'data-key': dataKey, + classNames: _classNames, + ...props + }: { + children: React.ReactNode + onClick?: () => void + 'data-key'?: string + classNames?: Record + }) => ( + + ), + } +}) + const mockUseQuery = useQuery as unknown as jest.Mock const mockUseParams = useParams as jest.Mock const mockHandleAppError = handleAppError as jest.Mock @@ -118,50 +222,54 @@ describe('MenteeProfilePage', () => { expect(screen.getByText('@test-mentee')).toBeInTheDocument() expect(screen.getByText('A test bio.')).toBeInTheDocument() - // Check stats - expect(screen.getByText('Total Issues')).toBeInTheDocument() - expect(screen.getByText('3')).toBeInTheDocument() - expect(screen.getByText('Open Issues')).toBeInTheDocument() - expect(screen.getByText('2')).toBeInTheDocument() - expect(screen.getByText('Closed Issues')).toBeInTheDocument() - expect(screen.getByText('1')).toBeInTheDocument() - // Check domains and skills const domainsHeading = screen.getByRole('heading', { name: /Domains/i }) const domainsContainer = domainsHeading.parentElement - expect(domainsContainer).not.toBeNull() - expect(within(domainsContainer as HTMLElement).getByTestId('label-list')).toHaveTextContent( + if (!domainsContainer) { + throw new Error('Domains container not found') + } + expect(within(domainsContainer).getByTestId('label-list')).toHaveTextContent( 'frontend, backend' ) const skillsHeading = screen.getByRole('heading', { name: /Skills & Technologies/i }) const skillsContainer = skillsHeading.parentElement - expect(skillsContainer).not.toBeNull() - expect(within(skillsContainer as HTMLElement).getByTestId('label-list')).toHaveTextContent( - 'react, nodejs' - ) + if (!skillsContainer) { + throw new Error('Skills container not found') + } + expect(within(skillsContainer).getByTestId('label-list')).toHaveTextContent('react, nodejs') - // Check issues - expect(screen.getByText('Open Issue 1')).toBeInTheDocument() - expect(screen.getByText('Closed Issue 1')).toBeInTheDocument() - expect(screen.getByText('Open Issue 2')).toBeInTheDocument() + // Check issues (appear in both desktop and mobile views) + const openIssue1Elements = screen.getAllByText('Open Issue 1') + const closedIssue1Elements = screen.getAllByText('Closed Issue 1') + const openIssue2Elements = screen.getAllByText('Open Issue 2') + expect(openIssue1Elements.length).toBeGreaterThan(0) + expect(closedIssue1Elements.length).toBeGreaterThan(0) + expect(openIssue2Elements.length).toBeGreaterThan(0) }) it('filters issues correctly when the dropdown is used', () => { mockUseQuery.mockReturnValue({ data: mockMenteeData, loading: false, error: undefined }) render() - const filterSelect = screen.getByRole('combobox') + const filterSelect = screen.getByTestId('select-trigger') // Filter for open issues - fireEvent.change(filterSelect, { target: { value: 'open' } }) - expect(screen.getByText('Open Issue 1')).toBeInTheDocument() - expect(screen.getByText('Open Issue 2')).toBeInTheDocument() + fireEvent.click(filterSelect) + const openOption = screen.getByText('Open (2)') + fireEvent.click(openOption) + const openIssue1Elements = screen.getAllByText('Open Issue 1') + const openIssue2Elements = screen.getAllByText('Open Issue 2') + expect(openIssue1Elements.length).toBeGreaterThan(0) + expect(openIssue2Elements.length).toBeGreaterThan(0) expect(screen.queryByText('Closed Issue 1')).not.toBeInTheDocument() // Filter for closed issues - fireEvent.change(filterSelect, { target: { value: 'closed' } }) - expect(screen.getByText('Closed Issue 1')).toBeInTheDocument() + fireEvent.click(filterSelect) + const closedOption = screen.getByText('Closed (1)') + fireEvent.click(closedOption) + const closedIssue1Elements = screen.getAllByText('Closed Issue 1') + expect(closedIssue1Elements.length).toBeGreaterThan(0) expect(screen.queryByText('Open Issue 1')).not.toBeInTheDocument() expect(screen.queryByText('Open Issue 2')).not.toBeInTheDocument() }) @@ -173,6 +281,7 @@ describe('MenteeProfilePage', () => { } mockUseQuery.mockReturnValue({ data: noIssuesData, loading: false, error: undefined }) render() - expect(screen.getByText('No issues assigned to this mentee in this module')).toBeInTheDocument() + const emptyMessages = screen.getAllByText('No issues found for the selected filter.') + expect(emptyMessages.length).toBeGreaterThan(0) }) }) diff --git a/frontend/src/app/my/mentorship/programs/[programKey]/modules/[moduleKey]/issues/page.tsx b/frontend/src/app/my/mentorship/programs/[programKey]/modules/[moduleKey]/issues/page.tsx index d5583561f3..21934208c5 100644 --- a/frontend/src/app/my/mentorship/programs/[programKey]/modules/[moduleKey]/issues/page.tsx +++ b/frontend/src/app/my/mentorship/programs/[programKey]/modules/[moduleKey]/issues/page.tsx @@ -2,18 +2,16 @@ import { useQuery } from '@apollo/client/react' import { Select, SelectItem } from '@heroui/select' -import { Tooltip } from '@heroui/tooltip' -import Image from 'next/image' import { useParams, useRouter, useSearchParams } from 'next/navigation' -import { useEffect, useMemo, useState } from 'react' +import { useCallback, useEffect, useMemo, useState } from 'react' import { ErrorDisplay, handleAppError } from 'app/global-error' import { GetModuleIssuesDocument } from 'types/__generated__/moduleQueries.generated' +import IssuesTable, { type IssueRow } from 'components/IssuesTable' import LoadingSpinner from 'components/LoadingSpinner' import Pagination from 'components/Pagination' const ITEMS_PER_PAGE = 20 const LABEL_ALL = 'all' -const MAX_VISIBLE_LABELS = 5 const IssuesPage = () => { const { programKey, moduleKey } = useParams<{ programKey: string; moduleKey: string }>() @@ -39,17 +37,8 @@ const IssuesPage = () => { }, [error]) const moduleData = data?.getModule - type ModuleIssueRow = { - objectID: string - number: number - title: string - state: string - isMerged: boolean - labels: string[] - assignees: Array<{ avatarUrl: string; login: string; name: string }> - } - const moduleIssues: ModuleIssueRow[] = useMemo(() => { + const moduleIssues: IssueRow[] = useMemo(() => { return (moduleData?.issues || []).map((i) => ({ objectID: i.id, number: i.number, @@ -92,9 +81,14 @@ const IssuesPage = () => { setCurrentPage(page) } - const handleIssueClick = (issueNumber: number) => { - router.push(`/my/mentorship/programs/${programKey}/modules/${moduleKey}/issues/${issueNumber}`) - } + const handleIssueClick = useCallback( + (issueNumber: number) => { + router.push( + `/my/mentorship/programs/${programKey}/modules/${moduleKey}/issues/${issueNumber}` + ) + }, + [router, programKey, moduleKey] + ) if (loading) return if (!moduleData) @@ -137,210 +131,12 @@ const IssuesPage = () => { - {/* Desktop Table - unchanged */} -
- - - - - - - - - - - {moduleIssues.map((issue) => ( - - - - - - - ))} - {moduleIssues.length === 0 && ( - - - - )} - -
- Title - - Status - - Labels - - Assignee -
- 50 ? false : true} - > - - - -
- - {issue.state === 'open' ? 'Open' : issue.isMerged ? 'Merged' : 'Closed'} - -
-
-
- {(() => { - const labels = issue.labels || [] - const visible = labels.slice(0, MAX_VISIBLE_LABELS) - const remaining = labels.length - visible.length - return ( - <> - {visible.map((label) => ( - - {label} - - ))} - {remaining > 0 && ( - - +{remaining} more - - )} - - ) - })()} -
-
- {issue.assignees?.length ? ( -
-
- {issue.assignees[0].login} - - {issue.assignees[0].login || issue.assignees[0].name} - -
- {issue.assignees.length > 1 && ( -
- +{issue.assignees.length - 1} -
- )} -
- ) : null} -
- No issues found for the selected filter. -
-
- - {/* Mobile & Tablet Cards */} -
- {moduleIssues.map((issue) => ( -
-
- - - {issue.state === 'open' ? 'Open' : issue.isMerged ? 'Merged' : 'Closed'} - -
- - {issue.labels?.length > 0 && ( -
- {issue.labels.slice(0, 3).map((label) => ( - - {label} - - ))} - {issue.labels.length > 3 && ( - - +{issue.labels.length - 3} - - )} -
- )} - - {issue.assignees?.length > 0 && ( -
- {issue.assignees[0].login} - - {issue.assignees[0].login || issue.assignees[0].name} - {issue.assignees.length > 1 && ` +${issue.assignees.length - 1}`} - -
- )} -
- ))} - - {moduleIssues.length === 0 && ( -
-

- No issues found for the selected filter. -

-
- )} -
+ {/* Pagination Controls */} { const { programKey, moduleKey, menteeKey } = useParams<{ programKey: string @@ -20,10 +27,13 @@ const MenteeProfilePage = () => { }>() const [menteeDetails, setMenteeDetails] = useState(null) - const [menteeIssues, setMenteeIssues] = useState([]) + const [menteeIssuesData, setMenteeIssuesData] = useState< + GetModuleMenteeDetailsQuery['getMenteeModuleIssues'] + >([]) const [isLoading, setIsLoading] = useState(true) const [statusFilter, setStatusFilter] = useState('all') + const [currentPage, setCurrentPage] = useState(1) const { data, error } = useQuery(GetModuleMenteeDetailsDocument, { variables: { @@ -38,7 +48,7 @@ const MenteeProfilePage = () => { useEffect(() => { if (data) { setMenteeDetails(data.getMenteeDetails ?? null) - setMenteeIssues(data.getMenteeModuleIssues ?? []) + setMenteeIssuesData(data.getMenteeModuleIssues ?? []) } if (error) { handleAppError(error) @@ -48,6 +58,19 @@ const MenteeProfilePage = () => { } }, [data, error]) + const menteeIssues: IssueRow[] = useMemo(() => { + return menteeIssuesData.map((issue) => ({ + objectID: issue.id, + number: issue.number, + title: issue.title, + state: issue.state, + isMerged: issue.isMerged, + labels: issue.labels || [], + assignees: issue.assignees || [], + url: issue.url, + })) + }, [menteeIssuesData]) + if (isLoading) return if (!menteeDetails) { @@ -63,12 +86,31 @@ const MenteeProfilePage = () => { const openIssues = menteeIssues.filter((issue) => issue.state.toLowerCase() === 'open') const closedIssues = menteeIssues.filter((issue) => issue.state.toLowerCase() === 'closed') - const issueMap: Record = { + const issueMap: Record = { all: menteeIssues, open: openIssues, closed: closedIssues, } - const filteredIssues = issueMap[statusFilter] || closedIssues + const filteredIssues = issueMap[statusFilter] || menteeIssues + + const totalPages = Math.ceil(filteredIssues.length / ITEMS_PER_PAGE) + const paginatedIssues = filteredIssues.slice( + (currentPage - 1) * ITEMS_PER_PAGE, + currentPage * ITEMS_PER_PAGE + ) + + const statusFilterOptions = [ + { key: 'all', label: 'All', count: menteeIssues.length }, + { key: 'open', label: 'Open', count: openIssues.length }, + { key: 'closed', label: 'Closed', count: closedIssues.length }, + ] + + const handleIssueClick = (issueNumber: number) => { + const issue = menteeIssues.find((i) => i.number === issueNumber) + if (issue?.url) { + window.open(issue.url, '_blank', 'noopener,noreferrer') + } + } return (
@@ -95,33 +137,6 @@ const MenteeProfilePage = () => {
- {/* Stats */} -
- -
-
- {menteeIssues.length} -
-
-
- - -
-
- {openIssues.length} -
-
-
- - -
-
- {closedIssues.length} -
-
-
-
- {/* Mentee Information */}
@@ -154,54 +169,59 @@ const MenteeProfilePage = () => {
)} - {/* Issues - moved to the end */} - - {menteeIssues.length === 0 ? ( -

- No issues assigned to this mentee in this module -

- ) : ( -
- {/* Filter Dropdown */} -
- -
- -
- {filteredIssues.map((issue) => ( -
+
+
+

Assigned Issues

+
+
+ +
- )} + + + + {/* Pagination Controls */} + +
diff --git a/frontend/src/components/IssuesTable.tsx b/frontend/src/components/IssuesTable.tsx new file mode 100644 index 0000000000..bc114c6321 --- /dev/null +++ b/frontend/src/components/IssuesTable.tsx @@ -0,0 +1,201 @@ +'use client' + +import { Tooltip } from '@heroui/tooltip' +import Image from 'next/image' +import { useRouter } from 'next/navigation' +import type React from 'react' + +export type IssueRow = { + objectID: string + number: number + title: string + state: string + isMerged?: boolean + labels: string[] + assignees?: Array<{ avatarUrl: string; login: string; name: string }> + url?: string + deadline?: string | null +} + +interface IssuesTableProps { + issues: IssueRow[] + showAssignee?: boolean + onIssueClick?: (issueNumber: number) => void + issueUrl?: (issueNumber: number) => string + maxVisibleLabels?: number + emptyMessage?: string +} + +const MAX_VISIBLE_LABELS = 5 + +const IssuesTable: React.FC = ({ + issues, + showAssignee = true, + onIssueClick, + issueUrl, + maxVisibleLabels = MAX_VISIBLE_LABELS, + emptyMessage = 'No issues found.', +}) => { + const router = useRouter() + + const handleIssueClick = (issueNumber: number) => { + if (onIssueClick) { + onIssueClick(issueNumber) + } else if (issueUrl) { + router.push(issueUrl(issueNumber)) + } + } + + const getStatusBadge = (state: string, isMerged?: boolean) => { + const statusMap: Record = { + open: { text: 'Open', class: 'bg-[#238636]' }, + merged: { text: 'Merged', class: 'bg-[#8657E5]' }, + closed: { text: 'Closed', class: 'bg-[#DA3633]' }, + } + + const statusKey = isMerged ? 'merged' : state.toLowerCase() + const status = statusMap[statusKey] || statusMap.closed + + return ( + + {status.text} + + ) + } + + const getColumnCount = () => { + let count = 3 + if (showAssignee) count++ + return count + } + + return ( +
+ + + + + + + {showAssignee && ( + + )} + + + + {issues.map((issue) => ( + + {/* Title */} + + + {/* Status */} + + + {/* Labels */} + + + {/* Assignee */} + {showAssignee && ( + + )} + + ))} + {issues.length === 0 && ( + + + + )} + +
+ Title + + Status + + Labels + + Assignee +
+
+ + + +
+
+
+ {getStatusBadge(issue.state, issue.isMerged)} +
+
+ {issue.labels && issue.labels.length > 0 ? ( +
+ {issue.labels.slice(0, maxVisibleLabels).map((label) => ( + + {label} + + ))} + {issue.labels.length > maxVisibleLabels && ( + + +{issue.labels.length - maxVisibleLabels} more + + )} +
+ ) : null} +
+ {issue.assignees && issue.assignees.length > 0 ? ( +
+ {issue.assignees[0].login} + + {issue.assignees[0].login || issue.assignees[0].name} + {issue.assignees.length > 1 && ` +${issue.assignees.length - 1}`} + +
+ ) : null} +
+ {emptyMessage} +
+
+ ) +} + +export default IssuesTable diff --git a/frontend/src/server/queries/menteeQueries.ts b/frontend/src/server/queries/menteeQueries.ts index 53db7fc7a7..87da93eb4f 100644 --- a/frontend/src/server/queries/menteeQueries.ts +++ b/frontend/src/server/queries/menteeQueries.ts @@ -22,6 +22,7 @@ export const GET_MODULE_MENTEE_DETAILS = gql` number title state + isMerged labels assignees { login diff --git a/frontend/src/types/__generated__/menteeQueries.generated.ts b/frontend/src/types/__generated__/menteeQueries.generated.ts index 07555d6be5..9f8dfb0c99 100644 --- a/frontend/src/types/__generated__/menteeQueries.generated.ts +++ b/frontend/src/types/__generated__/menteeQueries.generated.ts @@ -8,7 +8,7 @@ export type GetModuleMenteeDetailsQueryVariables = Types.Exact<{ }>; -export type GetModuleMenteeDetailsQuery = { getMenteeDetails: { __typename: 'MenteeNode', id: string, login: string, name: string, avatarUrl: string, bio: string | null, experienceLevel: string, domains: Array | null, tags: Array | null }, getMenteeModuleIssues: Array<{ __typename: 'IssueNode', id: string, number: number, title: string, state: string, labels: Array, createdAt: any, url: string, assignees: Array<{ __typename: 'UserNode', login: string, name: string, avatarUrl: string }> }> }; +export type GetModuleMenteeDetailsQuery = { getMenteeDetails: { __typename: 'MenteeNode', id: string, login: string, name: string, avatarUrl: string, bio: string | null, experienceLevel: string, domains: Array | null, tags: Array | null }, getMenteeModuleIssues: Array<{ __typename: 'IssueNode', id: string, number: number, title: string, state: string, isMerged: boolean, labels: Array, createdAt: any, url: string, assignees: Array<{ __typename: 'UserNode', login: string, name: string, avatarUrl: string }> }> }; -export const GetModuleMenteeDetailsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetModuleMenteeDetails"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"programKey"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"moduleKey"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"menteeKey"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"getMenteeDetails"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"programKey"},"value":{"kind":"Variable","name":{"kind":"Name","value":"programKey"}}},{"kind":"Argument","name":{"kind":"Name","value":"moduleKey"},"value":{"kind":"Variable","name":{"kind":"Name","value":"moduleKey"}}},{"kind":"Argument","name":{"kind":"Name","value":"menteeKey"},"value":{"kind":"Variable","name":{"kind":"Name","value":"menteeKey"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"login"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"avatarUrl"}},{"kind":"Field","name":{"kind":"Name","value":"bio"}},{"kind":"Field","name":{"kind":"Name","value":"experienceLevel"}},{"kind":"Field","name":{"kind":"Name","value":"domains"}},{"kind":"Field","name":{"kind":"Name","value":"tags"}}]}},{"kind":"Field","name":{"kind":"Name","value":"getMenteeModuleIssues"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"programKey"},"value":{"kind":"Variable","name":{"kind":"Name","value":"programKey"}}},{"kind":"Argument","name":{"kind":"Name","value":"moduleKey"},"value":{"kind":"Variable","name":{"kind":"Name","value":"moduleKey"}}},{"kind":"Argument","name":{"kind":"Name","value":"menteeKey"},"value":{"kind":"Variable","name":{"kind":"Name","value":"menteeKey"}}},{"kind":"Argument","name":{"kind":"Name","value":"limit"},"value":{"kind":"IntValue","value":"50"}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"number"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"state"}},{"kind":"Field","name":{"kind":"Name","value":"labels"}},{"kind":"Field","name":{"kind":"Name","value":"assignees"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"login"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"avatarUrl"}}]}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"url"}}]}}]}}]} as unknown as DocumentNode; \ No newline at end of file +export const GetModuleMenteeDetailsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetModuleMenteeDetails"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"programKey"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"moduleKey"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"menteeKey"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"getMenteeDetails"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"programKey"},"value":{"kind":"Variable","name":{"kind":"Name","value":"programKey"}}},{"kind":"Argument","name":{"kind":"Name","value":"moduleKey"},"value":{"kind":"Variable","name":{"kind":"Name","value":"moduleKey"}}},{"kind":"Argument","name":{"kind":"Name","value":"menteeKey"},"value":{"kind":"Variable","name":{"kind":"Name","value":"menteeKey"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"login"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"avatarUrl"}},{"kind":"Field","name":{"kind":"Name","value":"bio"}},{"kind":"Field","name":{"kind":"Name","value":"experienceLevel"}},{"kind":"Field","name":{"kind":"Name","value":"domains"}},{"kind":"Field","name":{"kind":"Name","value":"tags"}}]}},{"kind":"Field","name":{"kind":"Name","value":"getMenteeModuleIssues"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"programKey"},"value":{"kind":"Variable","name":{"kind":"Name","value":"programKey"}}},{"kind":"Argument","name":{"kind":"Name","value":"moduleKey"},"value":{"kind":"Variable","name":{"kind":"Name","value":"moduleKey"}}},{"kind":"Argument","name":{"kind":"Name","value":"menteeKey"},"value":{"kind":"Variable","name":{"kind":"Name","value":"menteeKey"}}},{"kind":"Argument","name":{"kind":"Name","value":"limit"},"value":{"kind":"IntValue","value":"50"}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"number"}},{"kind":"Field","name":{"kind":"Name","value":"title"}},{"kind":"Field","name":{"kind":"Name","value":"state"}},{"kind":"Field","name":{"kind":"Name","value":"isMerged"}},{"kind":"Field","name":{"kind":"Name","value":"labels"}},{"kind":"Field","name":{"kind":"Name","value":"assignees"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"login"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"avatarUrl"}}]}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"url"}}]}}]}}]} as unknown as DocumentNode; \ No newline at end of file