diff --git a/src/features/maintainers/components/pull-requests/PullRequestsTab.test.tsx b/src/features/maintainers/components/pull-requests/PullRequestsTab.test.tsx new file mode 100644 index 0000000..fb3d2fa --- /dev/null +++ b/src/features/maintainers/components/pull-requests/PullRequestsTab.test.tsx @@ -0,0 +1,84 @@ +// @vitest-environment jsdom +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { screen, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { PullRequestsTab } from './PullRequestsTab'; +import { renderWithTheme } from '../../../../test/renderWithTheme'; +import { getProjectPRs } from '../../../../shared/api/client'; + +vi.mock('../../../../shared/api/client', () => ({ + getProjectPRs: vi.fn(), +})); + +const mockGetProjectPRs = vi.mocked(getProjectPRs); + +const PROJECTS = [ + { + id: 'repo-1', + github_full_name: 'octo-org/frontend-app', + status: 'active', + }, +]; + +const PRS = [ + { + github_pr_id: 1, + number: 10, + state: 'open', + title: 'Fix login bug', + author_login: 'alice', + url: 'https://github.com/octo-org/frontend-app/pull/10', + merged: false, + created_at: '2026-06-20T12:00:00Z', + updated_at: '2026-06-21T12:00:00Z', + closed_at: null, + merged_at: null, + last_seen_at: '2026-06-21T12:00:00Z', + }, +]; + +describe('PullRequestsTab empty states', () => { + beforeEach(() => { + mockGetProjectPRs.mockReset(); + }); + + it('announces that repositories must be selected before pull requests can be shown', async () => { + renderWithTheme(); + + const status = await screen.findByRole('status'); + expect(status).toHaveTextContent('Select one or more repositories to view pull requests'); + expect(status).toHaveTextContent('Use the repository selector above to choose which repositories to include.'); + expect(mockGetProjectPRs).not.toHaveBeenCalled(); + }); + + it('announces that selected repositories currently have no pull requests', async () => { + mockGetProjectPRs.mockResolvedValue({ prs: [] }); + + renderWithTheme(); + + const status = await screen.findByRole('status'); + expect(status).toHaveTextContent('No pull requests were found in the selected repositories'); + expect(status).toHaveTextContent('Try a different repository selection or come back after new pull requests are opened.'); + expect(mockGetProjectPRs).toHaveBeenCalledWith('repo-1'); + }); + + it('announces no matches when filters exclude every pull request and clears back to results', async () => { + const user = userEvent.setup(); + mockGetProjectPRs.mockResolvedValue({ prs: PRS }); + + renderWithTheme(); + + expect(await screen.findByText('Fix login bug')).toBeInTheDocument(); + + await user.type(screen.getByPlaceholderText('Search pull request by title or author name...'), 'does-not-match'); + + const status = await screen.findByRole('status'); + expect(status).toHaveTextContent('No pull requests match the current search or state filters'); + expect(within(status).getByRole('button', { name: 'Clear filters' })).toBeInTheDocument(); + + await user.click(within(status).getByRole('button', { name: 'Clear filters' })); + + expect(await screen.findByText('Fix login bug')).toBeInTheDocument(); + expect(screen.queryByRole('status')).not.toBeInTheDocument(); + }); +}); diff --git a/src/features/maintainers/components/pull-requests/PullRequestsTab.tsx b/src/features/maintainers/components/pull-requests/PullRequestsTab.tsx index f24722a..24beeff 100644 --- a/src/features/maintainers/components/pull-requests/PullRequestsTab.tsx +++ b/src/features/maintainers/components/pull-requests/PullRequestsTab.tsx @@ -1,5 +1,5 @@ import { logger } from '../../../../shared/utils/logger'; -import { useState, useEffect } from 'react'; +import { useState, useEffect, useCallback } from 'react'; import { Search, AlertCircle } from 'lucide-react'; import { useTheme } from '../../../../shared/contexts/ThemeContext'; import { PRFilterType } from '../../types'; @@ -34,6 +34,43 @@ interface PullRequestsTabProps { onRefresh?: () => void; } +/** + * Explicit empty-state buckets for the PR table. + * + * Keeping these states separate avoids a generic "no rows" message and lets + * the UI tell the user whether they need to select repositories, wait for PRs + * to exist for the selected repositories, or clear filters. + */ +type EmptyStateKind = 'no-repos' | 'no-prs' | 'no-matches'; + +/** + * Returns the empty-state bucket to render after loading and errors have been + * ruled out. + */ +function getEmptyStateKind({ + selectedProjectCount, + totalPullRequests, + hasActiveFilters, +}: { + selectedProjectCount: number; + totalPullRequests: number; + hasActiveFilters: boolean; +}): EmptyStateKind | null { + if (selectedProjectCount === 0) { + return 'no-repos'; + } + + if (totalPullRequests === 0) { + return 'no-prs'; + } + + if (hasActiveFilters) { + return 'no-matches'; + } + + return null; +} + export function PullRequestsTab({ selectedProjects }: PullRequestsTabProps) { const { theme } = useTheme(); const [searchQuery, setSearchQuery] = useState(''); @@ -43,12 +80,7 @@ export function PullRequestsTab({ selectedProjects }: PullRequestsTabProps) { const [isLoading, setIsLoading] = useState(true); const [error, setError] = useState(null); - // Fetch PRs from selected projects - useEffect(() => { - loadPRs(); - }, [selectedProjects]); - - const loadPRs = async () => { + const loadPRs = useCallback(async () => { setIsLoading(true); setError(null); try { @@ -90,23 +122,26 @@ export function PullRequestsTab({ selectedProjects }: PullRequestsTabProps) { } finally { setIsLoading(false); } - }; + }, [selectedProjects]); + + // Fetch PRs from selected projects + useEffect(() => { + loadPRs(); + }, [loadPRs]); // Refresh PRs when selectedProjects change // Also refresh when page becomes visible (user switches back to tab) // And when repositories are refreshed (new repo added) useEffect(() => { const handleVisibilityChange = () => { - if (document.visibilityState === 'visible' && selectedProjects.length > 0) { + if (document.visibilityState === 'visible') { loadPRs(); } }; const handleRepositoriesRefreshed = () => { - // Refresh PRs when repositories are added/updated - if (selectedProjects.length > 0) { - loadPRs(); - } + // Refresh PRs when repositories are added or updated. + loadPRs(); }; document.addEventListener('visibilitychange', handleVisibilityChange); @@ -116,7 +151,7 @@ export function PullRequestsTab({ selectedProjects }: PullRequestsTabProps) { document.removeEventListener('visibilitychange', handleVisibilityChange); window.removeEventListener('repositories-refreshed', handleRepositoriesRefreshed); }; - }, [selectedProjects]); + }, [loadPRs]); // Filter PRs based on search and filter const filteredPRs = prs.filter(pr => { @@ -149,6 +184,15 @@ export function PullRequestsTab({ selectedProjects }: PullRequestsTabProps) { setFilter('All states'); }; + const hasActiveFilters = searchQuery.trim().length > 0 || filter !== 'All states'; + const emptyStateKind = !isLoading && !error + ? getEmptyStateKind({ + selectedProjectCount: selectedProjects.length, + totalPullRequests: prs.length, + hasActiveFilters, + }) + : null; + return (
{/* Clear Filters Button */} - + {hasActiveFilters && ( + + )}
{/* Pull Requests Table */} @@ -287,20 +334,64 @@ export function PullRequestsTab({ selectedProjects }: PullRequestsTabProps) { return ; }) ) : ( -
-

- {selectedProjects.length === 0 - ? 'Select repositories to view pull requests' - : 'No pull requests found in selected repositories'} -

- {selectedProjects.length === 0 && ( -

- Use the repository selector above to choose which repositories to view -

+
+ {emptyStateKind === 'no-repos' ? ( + <> +

+ Select one or more repositories to view pull requests +

+

+ Use the repository selector above to choose which repositories to include. +

+ + ) : emptyStateKind === 'no-prs' ? ( + <> +

+ No pull requests were found in the selected repositories +

+

+ Try a different repository selection or come back after new pull requests are opened. +

+ + ) : ( + <> +

+ No pull requests match the current search or state filters +

+

+ Clear the search or state filter to bring rows back into view. +

+ + )}
)} @@ -326,4 +417,4 @@ function formatTimeAgo(dateString: string): string { if (diffDays < 30) return `${diffDays} ${diffDays === 1 ? 'day' : 'days'} ago`; if (diffMonths < 12) return `${diffMonths} ${diffMonths === 1 ? 'month' : 'months'} ago`; return `${diffYears} ${diffYears === 1 ? 'year' : 'years'} ago`; -} \ No newline at end of file +} diff --git a/vite.config.ts b/vite.config.ts index e64a756..a8de1a7 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,10 +1,13 @@ /// import { defineConfig } from 'vitest/config' import path from 'path' +import { fileURLToPath } from 'url' import tailwindcss from '@tailwindcss/vite' import react from '@vitejs/plugin-react' import { visualizer } from 'rollup-plugin-visualizer' +const __dirname = path.dirname(fileURLToPath(import.meta.url)) + export default defineConfig({ plugins: [ // The React and Tailwind plugins are both required for Make, even if diff --git a/vitest.config.ts b/vitest.config.ts index 58cd122..7233427 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -1,6 +1,9 @@ import { defineConfig } from 'vitest/config'; import react from '@vitejs/plugin-react'; import path from 'path'; +import { fileURLToPath } from 'url'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); export default defineConfig({ plugins: [react()],