Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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(<PullRequestsTab selectedProjects={[]} />);

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(<PullRequestsTab selectedProjects={PROJECTS} />);

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(<PullRequestsTab selectedProjects={PROJECTS} />);

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();
});
});
169 changes: 130 additions & 39 deletions src/features/maintainers/components/pull-requests/PullRequestsTab.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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('');
Expand All @@ -43,12 +80,7 @@ export function PullRequestsTab({ selectedProjects }: PullRequestsTabProps) {
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);

// Fetch PRs from selected projects
useEffect(() => {
loadPRs();
}, [selectedProjects]);

const loadPRs = async () => {
const loadPRs = useCallback(async () => {
setIsLoading(true);
setError(null);
try {
Expand Down Expand Up @@ -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);
Expand All @@ -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 => {
Expand Down Expand Up @@ -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 (
<div className={`backdrop-blur-[40px] rounded-[24px] border p-8 transition-colors ${
theme === 'dark'
Expand Down Expand Up @@ -195,16 +239,19 @@ export function PullRequestsTab({ selectedProjects }: PullRequestsTabProps) {
/>

{/* Clear Filters Button */}
<button
className={`px-5 py-3 rounded-[14px] backdrop-blur-[25px] border transition-all ${
theme === 'dark'
? 'bg-white/[0.08] border-white/20 hover:bg-white/[0.12] hover:border-[#c9983a]/30 text-[#b8a898]'
: 'bg-white/[0.15] border-white/25 hover:bg-white/[0.2] hover:border-[#c9983a]/30 text-[#7a6b5a]'
}`}
onClick={handleClearFilters}
>
<span className="text-[14px] font-semibold">Clear filters</span>
</button>
{hasActiveFilters && (
<button
className={`px-5 py-3 rounded-[14px] backdrop-blur-[25px] border transition-all ${
theme === 'dark'
? 'bg-white/[0.08] border-white/20 hover:bg-white/[0.12] hover:border-[#c9983a]/30 text-[#b8a898]'
: 'bg-white/[0.15] border-white/25 hover:bg-white/[0.2] hover:border-[#c9983a]/30 text-[#7a6b5a]'
}`}
onClick={handleClearFilters}
type="button"
>
<span className="text-[14px] font-semibold">Clear filters</span>
</button>
)}
</div>

{/* Pull Requests Table */}
Expand Down Expand Up @@ -287,20 +334,64 @@ export function PullRequestsTab({ selectedProjects }: PullRequestsTabProps) {
return <PRRow key={`${pr.github_pr_id}-${pr.projectName}`} pr={prForComponent} />;
})
) : (
<div className="text-center py-12">
<p className={`text-[14px] font-medium mb-1 transition-colors ${
theme === 'dark' ? 'text-[#b8a898]' : 'text-[#7a6b5a]'
}`}>
{selectedProjects.length === 0
? 'Select repositories to view pull requests'
: 'No pull requests found in selected repositories'}
</p>
{selectedProjects.length === 0 && (
<p className={`text-[12px] transition-colors ${
theme === 'dark' ? 'text-[#8a7b6a]' : 'text-[#9a8b7a]'
}`}>
Use the repository selector above to choose which repositories to view
</p>
<div
className={`text-center py-12 px-6 rounded-[16px] border ${
theme === 'dark' ? 'bg-white/[0.04] border-white/10' : 'bg-white/[0.08] border-white/15'
}`}
role="status"
aria-live="polite"
aria-atomic="true"
>
{emptyStateKind === 'no-repos' ? (
<>
<p className={`text-[14px] font-medium mb-1 transition-colors ${
theme === 'dark' ? 'text-[#e8dfd0]' : 'text-[#2d2820]'
}`}>
Select one or more repositories to view pull requests
</p>
<p className={`text-[12px] transition-colors ${
theme === 'dark' ? 'text-[#8a7b6a]' : 'text-[#9a8b7a]'
}`}>
Use the repository selector above to choose which repositories to include.
</p>
</>
) : emptyStateKind === 'no-prs' ? (
<>
<p className={`text-[14px] font-medium mb-1 transition-colors ${
theme === 'dark' ? 'text-[#e8dfd0]' : 'text-[#2d2820]'
}`}>
No pull requests were found in the selected repositories
</p>
<p className={`text-[12px] transition-colors ${
theme === 'dark' ? 'text-[#8a7b6a]' : 'text-[#9a8b7a]'
}`}>
Try a different repository selection or come back after new pull requests are opened.
</p>
</>
) : (
<>
<p className={`text-[14px] font-medium mb-1 transition-colors ${
theme === 'dark' ? 'text-[#e8dfd0]' : 'text-[#2d2820]'
}`}>
No pull requests match the current search or state filters
</p>
<p className={`text-[12px] mb-4 transition-colors ${
theme === 'dark' ? 'text-[#8a7b6a]' : 'text-[#9a8b7a]'
}`}>
Clear the search or state filter to bring rows back into view.
</p>
<button
className={`px-5 py-3 rounded-[14px] backdrop-blur-[25px] border transition-all ${
theme === 'dark'
? 'bg-white/[0.08] border-white/20 hover:bg-white/[0.12] hover:border-[#c9983a]/30 text-[#b8a898]'
: 'bg-white/[0.15] border-white/25 hover:bg-white/[0.2] hover:border-[#c9983a]/30 text-[#7a6b5a]'
}`}
onClick={handleClearFilters}
type="button"
>
<span className="text-[14px] font-semibold">Clear filters</span>
</button>
</>
)}
</div>
)}
Expand All @@ -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`;
}
}
3 changes: 3 additions & 0 deletions vite.config.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
/// <reference types="vitest/config" />
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
Expand Down
3 changes: 3 additions & 0 deletions vitest.config.ts
Original file line number Diff line number Diff line change
@@ -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()],
Expand Down