From e0d5e152a931e0b02c2335c57041e9976f45e4c2 Mon Sep 17 00:00:00 2001 From: Dushyant Acharya Date: Thu, 1 Oct 2026 19:54:35 +0530 Subject: [PATCH 1/4] feat(issues): implement dedicated open issues explorer page - Create src/pages/IssuesPage.jsx for browsing open issues across repositories and teams - Add support for repository filtering, organization filtering, state tabs (open/closed/all), and real-time search - Implement flexible grouping options (Flat list, By Repository, By Author) with pagination - Display issue cards with labels, authors, assignees, comment counts, and direct GitHub links - Add src/pages/IssuesPage.test.jsx unit test suite - Register /issues route in App.jsx and add Issues link to Navbar.jsx --- src/App.jsx | 2 + src/components/Navbar.jsx | 1 + src/pages/IssuesPage.jsx | 504 ++++++++++++++++++++++++++++++++++ src/pages/IssuesPage.test.jsx | 67 +++++ 4 files changed, 574 insertions(+) create mode 100644 src/pages/IssuesPage.jsx create mode 100644 src/pages/IssuesPage.test.jsx diff --git a/src/App.jsx b/src/App.jsx index 56d854d2..1484e44a 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -12,6 +12,7 @@ import ContributorProfilePage from './pages/ContributorProfilePage' import NetworkPage from './pages/NetworkPage' import AnalyticsPage from './pages/AnalyticsPage' import GovernancePage from './pages/GovernancePage' +import IssuesPage from './pages/IssuesPage' import SettingsPage from './pages/SettingsPage' import Footer from './components/layout/Footer' import Support from './pages/Support' @@ -37,6 +38,7 @@ function AppContent() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/src/components/Navbar.jsx b/src/components/Navbar.jsx index bdafb79e..805edf00 100644 --- a/src/components/Navbar.jsx +++ b/src/components/Navbar.jsx @@ -10,6 +10,7 @@ const LINKS = [ { to: '/overview', label: 'Overview' }, { to: '/repositories', label: 'Repositories' }, { to: '/contributors', label: 'Contributors' }, + { to: '/issues', label: 'Issues' }, { to: '/network', label: 'Network' }, { to: '/analytics', label: 'Analytics' }, { to: '/governance', label: 'Governance' }, diff --git a/src/pages/IssuesPage.jsx b/src/pages/IssuesPage.jsx new file mode 100644 index 00000000..ca35b046 --- /dev/null +++ b/src/pages/IssuesPage.jsx @@ -0,0 +1,504 @@ +import React, { useState, useMemo } from 'react' +import { + FiAlertCircle, + FiCheckCircle, + FiMessageSquare, + FiUser, + FiFolder, + FiSearch, + FiExternalLink, + FiClock, + FiDatabase, + FiTag, + FiFilter +} from 'react-icons/fi' +import { useApp } from '../context/AppContext' +import { C, PageTitle } from '../components/UI' +import EmptyStateCard from '../components/EmptyStateCard' +import AnalysisBanner from '../components/AnalysisBanner' +import { useNavigate } from 'react-router-dom' + +export default function IssuesPage() { + const { model, issuesData, loading } = useApp() + const navigate = useNavigate() + + const [search, setSearch] = useState('') + const [orgFilter, setOrgFilter] = useState('All Organizations') + const [repoFilter, setRepoFilter] = useState('All Repositories') + const [stateFilter, setStateFilter] = useState('open') // 'open', 'closed', 'all' + const [groupBy, setGroupBy] = useState('none') // 'none', 'repo', 'author' + const [page, setPage] = useState(1) + const pageSize = 20 + + // Aggregate all issues from issuesData + const allIssues = useMemo(() => { + if (!issuesData || Object.keys(issuesData).length === 0) return [] + const list = [] + Object.entries(issuesData).forEach(([repoKey, issues]) => { + if (!Array.isArray(issues)) return + const [orgLogin, repoName] = repoKey.split('/') + issues.forEach(item => { + // Exclude pull requests (GitHub API includes PRs in issues endpoint) + if (item.pull_request) return + list.push({ + ...item, + orgLogin: orgLogin || item.repository_url?.split('/')?.slice(-2, -1)[0] || '', + repoName: repoName || item.repository_url?.split('/')?.slice(-1)[0] || '', + repoKey, + }) + }) + }) + return list + }, [issuesData]) + + // Extract orgs list for filter dropdown + const orgList = useMemo(() => { + const orgs = new Set(allIssues.map(i => i.orgLogin).filter(Boolean)) + return ['All Organizations', ...Array.from(orgs)] + }, [allIssues]) + + // Extract repos list for filter dropdown + const repoList = useMemo(() => { + const repos = new Set( + allIssues + .filter(i => orgFilter === 'All Organizations' || i.orgLogin === orgFilter) + .map(i => i.repoName) + .filter(Boolean) + ) + return ['All Repositories', ...Array.from(repos)] + }, [allIssues, orgFilter]) + + // Filter issues + const filteredIssues = useMemo(() => { + return allIssues.filter(issue => { + // State filter + if (stateFilter !== 'all' && issue.state !== stateFilter) return false + + // Org filter + if (orgFilter !== 'All Organizations' && issue.orgLogin !== orgFilter) return false + + // Repo filter + if (repoFilter !== 'All Repositories' && issue.repoName !== repoFilter) return false + + // Search filter + if (search.trim()) { + const q = search.toLowerCase().trim() + const titleMatch = issue.title?.toLowerCase().includes(q) + const numberMatch = String(issue.number).includes(q) + const authorMatch = issue.user?.login?.toLowerCase().includes(q) + const assigneeMatch = issue.assignee?.login?.toLowerCase().includes(q) + const labelMatch = issue.labels?.some(l => l.name?.toLowerCase().includes(q)) + if (!titleMatch && !numberMatch && !authorMatch && !assigneeMatch && !labelMatch) { + return false + } + } + return true + }) + }, [allIssues, stateFilter, orgFilter, repoFilter, search]) + + // Summary Metrics + const metrics = useMemo(() => { + const openCount = allIssues.filter(i => i.state === 'open').length + const closedCount = allIssues.filter(i => i.state === 'closed').length + const affectedRepos = new Set(allIssues.map(i => i.repoKey)).size + const authors = new Set(allIssues.map(i => i.user?.login).filter(Boolean)).size + return { openCount, closedCount, affectedRepos, authors } + }, [allIssues]) + + // Reset page when filters change + useMemo(() => { + setPage(1) + }, [search, orgFilter, repoFilter, stateFilter, groupBy]) + + // Grouping logic + const groupedIssues = useMemo(() => { + if (groupBy === 'repo') { + const map = {} + filteredIssues.forEach(issue => { + const key = `${issue.orgLogin}/${issue.repoName}` + if (!map[key]) map[key] = [] + map[key].push(issue) + }) + return map + } else if (groupBy === 'author') { + const map = {} + filteredIssues.forEach(issue => { + const key = issue.user?.login || 'Unknown' + if (!map[key]) map[key] = [] + map[key].push(issue) + }) + return map + } + return null + }, [filteredIssues, groupBy]) + + // Pagination for flat list view + const totalPages = Math.ceil(filteredIssues.length / pageSize) || 1 + const paginatedIssues = useMemo(() => { + if (groupBy !== 'none') return filteredIssues + const start = (page - 1) * pageSize + return filteredIssues.slice(start, start + pageSize) + }, [filteredIssues, page, pageSize, groupBy]) + + if (loading) { + return ( +
+ +
+ Loading issue data across repositories... +
+
+ ) + } + + if (!model || allIssues.length === 0) { + return ( +
+ + +
+ } + title="No Issues Data Available" + description="Run an analysis from the home page to inspect issues across your repositories." + buttonText="Go to Home" + onButtonClick={() => navigate('/')} + /> +
+
+ ) + } + + return ( +
+ + + + + {/* Summary Metrics */} +
+
+
+ Open Issues +
+
{metrics.openCount.toLocaleString()}
+
+ +
+
+ Closed Issues +
+
{metrics.closedCount.toLocaleString()}
+
+ +
+
+ Repositories +
+
{metrics.affectedRepos.toLocaleString()}
+
+ +
+
+ Issue Creators +
+
{metrics.authors.toLocaleString()}
+
+
+ + {/* Filter & Controls Bar */} +
+
+ {/* Search Box */} +
+ + setSearch(e.target.value)} + style={{ ...C.input, width: '100%', paddingLeft: 34 }} + /> +
+ + {/* Org Filter */} + {orgList.length > 2 && ( + + )} + + {/* Repo Filter */} + + + {/* State Filter Tabs */} +
+ {['open', 'closed', 'all'].map(s => ( + + ))} +
+ + {/* Group By Selector */} +
+ Group by: + +
+
+
+ + {/* Results Header */} +
+
+ Showing {filteredIssues.length} {stateFilter} {filteredIssues.length === 1 ? 'issue' : 'issues'} +
+
+ + {/* Issue Items / Grouped Views */} + {filteredIssues.length === 0 ? ( +
+ No issues match your current filter criteria. +
+ ) : groupBy !== 'none' ? ( + // Grouped View (by Repo or Author) +
+ {Object.entries(groupedIssues || {}).map(([groupKey, issues]) => ( +
+
+ {groupBy === 'repo' ? : } + {groupKey} + + {issues.length} {issues.length === 1 ? 'issue' : 'issues'} + +
+
+ {issues.map(issue => ( + + ))} +
+
+ ))} +
+ ) : ( + // Flat List View with Pagination +
+
+ {paginatedIssues.map(issue => ( + + ))} +
+ + {/* Pagination Controls */} + {totalPages > 1 && ( +
+ + + Page {page} of {totalPages} + + +
+ )} +
+ )} +
+ ) +} + +// Sub-component for individual issue row +function IssueRow({ issue }) { + const isOpen = issue.state === 'open' + const createdDate = new Date(issue.created_at).toLocaleDateString(undefined, { + year: 'numeric', + month: 'short', + day: 'numeric' + }) + + return ( +
+ {/* State Icon */} +
+ {isOpen ? ( + + ) : ( + + )} +
+ + {/* Main Content */} +
+ + + {/* Labels */} + {issue.labels && issue.labels.length > 0 && ( +
+ {issue.labels.map(l => { + const bgHex = l.color ? `#${l.color}` : 'var(--surface)' + return ( + + {l.name} + + ) + })} +
+ )} + + {/* Meta Line */} +
+ + {issue.orgLogin}/{issue.repoName} + + + + {createdDate} + + + {issue.user && ( + + {issue.user.avatar_url && ( + {issue.user.login} + )} + Author: {issue.user.login} + + )} + + {issue.assignee ? ( + + {issue.assignee.avatar_url && ( + {issue.assignee.login} + )} + Assignee: {issue.assignee.login} + + ) : ( + Unassigned + )} +
+
+ + {/* Comment Count */} + {issue.comments > 0 && ( +
+ + {issue.comments} +
+ )} +
+ ) +} diff --git a/src/pages/IssuesPage.test.jsx b/src/pages/IssuesPage.test.jsx new file mode 100644 index 00000000..859b0157 --- /dev/null +++ b/src/pages/IssuesPage.test.jsx @@ -0,0 +1,67 @@ +import { describe, it, expect, vi } from 'vitest' +import { render, screen } from '@testing-library/react' +import { MemoryRouter } from 'react-router-dom' +import IssuesPage from './IssuesPage' + +vi.mock('../context/AppContext', () => ({ + useApp: () => ({ + model: { + totalRepos: [ + { orgLogin: 'AOSSIE', name: 'OrgExplorer' } + ] + }, + issuesData: { + 'AOSSIE/OrgExplorer': [ + { + id: 101, + number: 1, + title: 'First issue for testing', + state: 'open', + created_at: '2026-09-01T10:00:00Z', + html_url: 'https://github.com/AOSSIE/OrgExplorer/issues/1', + user: { login: 'tester1', avatar_url: '' }, + assignee: { login: 'maintainer1', avatar_url: '' }, + labels: [{ id: 1, name: 'bug', color: 'ff0000' }], + comments: 3 + }, + { + id: 102, + number: 2, + title: 'Pull request item that should be excluded', + state: 'open', + created_at: '2026-09-02T10:00:00Z', + html_url: 'https://github.com/AOSSIE/OrgExplorer/pull/2', + pull_request: { url: 'https://api.github.com/repos/AOSSIE/OrgExplorer/pulls/2' }, + user: { login: 'tester2' } + } + ] + }, + loading: false + }) +})) + +describe('IssuesPage', () => { + it('renders issues page title and metrics correctly', () => { + render( + + + + ) + + expect(screen.getByText('Issues Explorer')).toBeInTheDocument() + expect(screen.getByText('First issue for testing')).toBeInTheDocument() + expect(screen.queryByText('Pull request item that should be excluded')).not.toBeInTheDocument() + }) + + it('filters issues by state correctly', () => { + render( + + + + ) + + expect(screen.getByText('#1')).toBeInTheDocument() + expect(screen.getByText('tester1')).toBeInTheDocument() + expect(screen.getByText('bug')).toBeInTheDocument() + }) +}) From e8aeb040d84c09807e3dc959d9662d3e89f960e0 Mon Sep 17 00:00:00 2001 From: Dushyant Acharya Date: Sun, 4 Oct 2026 01:15:49 +0530 Subject: [PATCH 2/4] fix(issues): add filter accessibility attributes and expand state filter test coverage --- src/pages/IssuesPage.jsx | 7 ++++++- src/pages/IssuesPage.test.jsx | 28 +++++++++++++++++++++++++--- 2 files changed, 31 insertions(+), 4 deletions(-) diff --git a/src/pages/IssuesPage.jsx b/src/pages/IssuesPage.jsx index ca35b046..ecf041a6 100644 --- a/src/pages/IssuesPage.jsx +++ b/src/pages/IssuesPage.jsx @@ -217,6 +217,7 @@ export default function IssuesPage() { setSearch(e.target.value)} @@ -227,6 +228,7 @@ export default function IssuesPage() { {/* Org Filter */} {orgList.length > 2 && ( setRepoFilter(e.target.value)} style={C.select} @@ -249,6 +252,8 @@ export default function IssuesPage() { {['open', 'closed', 'all'].map(s => (