diff --git a/src/components/CommunityPortal/Activities/__tests__/ActivityList.test.jsx b/src/components/CommunityPortal/Activities/__tests__/ActivityList.test.jsx
index d5751c59432..f0b1639a039 100644
--- a/src/components/CommunityPortal/Activities/__tests__/ActivityList.test.jsx
+++ b/src/components/CommunityPortal/Activities/__tests__/ActivityList.test.jsx
@@ -57,6 +57,7 @@ describe('ActivityList', () => {
test('sorts activities by date (latest to earliest)', () => {
renderActivityList();
+ fireEvent.click(screen.getByLabelText(/Show Past Events:/i));
fireEvent.change(screen.getByLabelText(/Sort By:/i), {
target: { value: 'latest' },
diff --git a/src/components/PRGradingDashboard/PRGradingDashboard.export.test.jsx b/src/components/PRGradingDashboard/PRGradingDashboard.export.test.jsx
new file mode 100644
index 00000000000..9d323daa02e
--- /dev/null
+++ b/src/components/PRGradingDashboard/PRGradingDashboard.export.test.jsx
@@ -0,0 +1,58 @@
+import axios from 'axios';
+import { fireEvent, render, screen, waitFor, within } from '@testing-library/react';
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+import { downloadPRGradingCsv } from '../PRGradingScreen/csvExport';
+import PRGradingDashboard from './PRGradingDashboard';
+
+vi.mock('axios');
+vi.mock('react-redux', () => ({
+ useDispatch: () => () => false,
+ useSelector: selector => selector({ theme: { darkMode: false }, auth: { user: null } }),
+}));
+vi.mock('../PRGradingScreen/csvExport', () => ({
+ downloadPRGradingCsv: vi.fn(),
+}));
+
+const gradings = [
+ {
+ reviewer: 'Alice',
+ prsReviewed: 7,
+ prsNeeded: 10,
+ gradedPrs: [{ prNumbers: '1070 + 1256', grade: 'Exceptional' }],
+ },
+];
+
+describe('Team Alpha dashboard CSV export', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ axios.get.mockResolvedValue({ data: gradings });
+ });
+
+ it('exports the displayed grading data after selecting a review week', async () => {
+ render();
+
+ const exportButton = await screen.findByRole('button', {
+ name: 'Export to CSV',
+ });
+ expect(
+ within(screen.getByRole('group', { name: 'Review week actions' })).getByRole('button', {
+ name: 'Export to CSV',
+ }),
+ ).toBe(exportButton);
+ fireEvent.change(screen.getByLabelText('Review Week'), { target: { value: '2 Weeks Ago' } });
+ fireEvent.click(await screen.findByRole('button', { name: 'Export to CSV' }));
+
+ await waitFor(() => {
+ expect(downloadPRGradingCsv).toHaveBeenCalledExactlyOnceWith(
+ [
+ {
+ ...gradings[0],
+ gradedPrs: [{ ...gradings[0].gradedPrs[0], isNew: false }],
+ },
+ ],
+ 'team-alpha-pr-grading-displayed-data.csv',
+ { useStoredCount: true },
+ );
+ });
+ });
+});
diff --git a/src/components/PRGradingDashboard/PRGradingDashboard.jsx b/src/components/PRGradingDashboard/PRGradingDashboard.jsx
index 82e8a9afe38..11cf860bbce 100644
--- a/src/components/PRGradingDashboard/PRGradingDashboard.jsx
+++ b/src/components/PRGradingDashboard/PRGradingDashboard.jsx
@@ -2,6 +2,7 @@ import axios from 'axios';
import { useEffect, useState } from 'react';
import { useSelector } from 'react-redux';
import { toast } from 'react-toastify';
+import { downloadPRGradingCsv } from '../PRGradingScreen/csvExport';
import AddReviewerModal from './AddReviewerModal';
import ConfirmationModal from './ConfirmationModal';
import GradingTable from './GradingTable';
@@ -48,7 +49,7 @@ function PRGradingDashboard() {
// Fetch data on mount
useEffect(() => {
- fetchGradings();
+ void fetchGradings();
}, []);
const fetchGradings = async () => {
@@ -231,6 +232,8 @@ function PRGradingDashboard() {
day: 'numeric',
});
const selectedMockWeekMeta = MOCK_WEEK_METADATA[selectedMockWeek];
+ // Apply future week filtering here so the table, summary, and CSV use the same rows.
+ const displayedGradings = gradings;
if (loading) {
return (
@@ -253,7 +256,24 @@ function PRGradingDashboard() {
{TEAM_NAME}
{currentDate}
-
+
+
@@ -289,7 +309,7 @@ function PRGradingDashboard() {
Summary
diff --git a/src/components/PRGradingDashboard/PRGradingDashboard.module.css b/src/components/PRGradingDashboard/PRGradingDashboard.module.css
index 5c33d26c3bc..76e78458818 100644
--- a/src/components/PRGradingDashboard/PRGradingDashboard.module.css
+++ b/src/components/PRGradingDashboard/PRGradingDashboard.module.css
@@ -33,6 +33,21 @@
display: flex;
justify-content: flex-end;
align-items: flex-start;
+ gap: 0.75rem;
+ min-width: 0;
+ margin: 0;
+ padding: 0;
+ border: 0;
+}
+
+.headerControlsLegend {
+ position: absolute;
+ width: 1px;
+ height: 1px;
+ padding: 0;
+ overflow: hidden;
+ clip-path: inset(50%);
+ white-space: nowrap;
}
.mockWeekControl {
@@ -97,6 +112,23 @@
margin-bottom: 1rem;
}
+.exportButton {
+ align-self: center;
+ padding: 0.5rem 1rem;
+ background-color: transparent;
+ color: #1d4ed8;
+ font-size: 0.875rem;
+ font-weight: 600;
+ border: 1px solid #2563eb;
+ border-radius: 0.375rem;
+ cursor: pointer;
+}
+
+.exportButton:hover,
+.exportButton:focus-visible {
+ background-color: #eff6ff;
+}
+
.addReviewerButton {
padding: 0.5rem 1rem;
background-color: #2563eb;
@@ -254,6 +286,16 @@
border-color: #2563eb;
}
+:global(.dark-mode) .exportButton {
+ color: #93c5fd;
+ border-color: #60a5fa;
+}
+
+:global(.dark-mode) .exportButton:hover,
+:global(.dark-mode) .exportButton:focus-visible {
+ background-color: #1e3a5f;
+}
+
:global(.dark-mode) .addReviewerButton:hover {
background-color: #1d4ed8;
}
@@ -289,10 +331,17 @@
.headerControls {
width: 100%;
+ align-items: stretch;
+ flex-direction: column;
}
.mockWeekControl {
width: 100%;
+ order: -1;
+ }
+
+ .exportButton {
+ align-self: stretch;
}
.mockWeekMeta {
diff --git a/src/components/PRGradingScreen/PRGradingScreen.export.test.jsx b/src/components/PRGradingScreen/PRGradingScreen.export.test.jsx
new file mode 100644
index 00000000000..f5f31b269c5
--- /dev/null
+++ b/src/components/PRGradingScreen/PRGradingScreen.export.test.jsx
@@ -0,0 +1,67 @@
+import { fireEvent, render, screen, within } from '@testing-library/react';
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+import PRGradingScreen from './PRGradingScreen';
+import { downloadWeeklyPRGradingCsv } from './csvExport';
+
+vi.mock('react-redux', () => ({
+ useSelector: selector => selector({ theme: { darkMode: false } }),
+}));
+
+vi.mock('./csvExport', () => ({
+ downloadWeeklyPRGradingCsv: vi.fn(),
+}));
+
+const dateRange = { start: '5/18/2025', end: '5/25/2025' };
+const teamData = { teamName: '91NePRT', dateRange };
+const reviewers = [
+ { id: '1', reviewer: 'Alice', role: 'Mentor', prsNeeded: 2, gradedPrs: [] },
+ { id: '2', reviewer: 'Bob', role: 'Reviewer', prsNeeded: 1, gradedPrs: [] },
+ { id: '3', reviewer: 'Alex', role: 'Reviewer', prsNeeded: 1, gradedPrs: [] },
+];
+
+describe('weekly PR grading Export to CSV button', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it('exports only reviewers matching the current search and role filters', () => {
+ render();
+
+ const headerActions = within(screen.getByRole('group', { name: 'Grading actions' }));
+ expect(headerActions.getByRole('button', { name: 'Export to CSV' })).toBeInTheDocument();
+ expect(headerActions.getByRole('button', { name: 'Done' })).toBeInTheDocument();
+ expect(screen.getAllByRole('button', { name: /Promote/ })).toHaveLength(reviewers.length);
+
+ fireEvent.change(screen.getByPlaceholderText('Search reviewers by name...'), {
+ target: { value: 'al' },
+ });
+ fireEvent.change(screen.getByRole('combobox'), { target: { value: 'Mentor' } });
+ fireEvent.click(screen.getByRole('button', { name: 'Export to CSV' }));
+
+ expect(downloadWeeklyPRGradingCsv).toHaveBeenCalledExactlyOnceWith([reviewers[0]], dateRange);
+ });
+
+ it('exports a newly added PR without requiring Done', () => {
+ render();
+
+ fireEvent.click(screen.getAllByRole('button', { name: '+ Add new' })[0]);
+ fireEvent.change(screen.getByPlaceholderText('1070 or 1070 + 1256'), {
+ target: { value: '1234' },
+ });
+ fireEvent.click(screen.getByRole('button', { name: 'Add' }));
+ fireEvent.click(screen.getByRole('button', { name: 'Export to CSV' }));
+
+ expect(downloadWeeklyPRGradingCsv).toHaveBeenCalledExactlyOnceWith(
+ [
+ expect.objectContaining({
+ reviewer: 'Alice',
+ prsReviewed: 1,
+ gradedPrs: [expect.objectContaining({ prNumbers: '1234', grade: 'Okay' })],
+ }),
+ reviewers[1],
+ reviewers[2],
+ ],
+ dateRange,
+ );
+ });
+});
diff --git a/src/components/PRGradingScreen/PRGradingScreen.jsx b/src/components/PRGradingScreen/PRGradingScreen.jsx
index 25a118011c8..00565b29845 100644
--- a/src/components/PRGradingScreen/PRGradingScreen.jsx
+++ b/src/components/PRGradingScreen/PRGradingScreen.jsx
@@ -4,6 +4,7 @@ import { useSelector } from 'react-redux';
import { v4 as uuidv4 } from 'uuid';
import PRGradingView from './PRGradingView';
import { usePRPromotion } from './usePRPromotion';
+import { downloadWeeklyPRGradingCsv } from './csvExport';
const PRGradingScreen = ({ teamData, reviewers }) => {
const darkMode = useSelector(state => state.theme.darkMode);
@@ -109,6 +110,9 @@ const PRGradingScreen = ({ teamData, reviewers }) => {
const handleCloseGradingModal = () => setShowGradingModal(null);
const handleFinalize = () => setIsFinalized(true);
+ const handleExport = () => {
+ downloadWeeklyPRGradingCsv(filteredReviewers, teamData.dateRange);
+ };
if (!teamData || !reviewers) {
return Error: Missing required props
;
@@ -120,6 +124,7 @@ const PRGradingScreen = ({ teamData, reviewers }) => {
teamData={teamData}
isFinalized={isFinalized}
onFinalize={handleFinalize}
+ onExport={handleExport}
searchTerm={searchTerm}
onSearchTermChange={setSearchTerm}
roleFilter={roleFilter}
diff --git a/src/components/PRGradingScreen/PRGradingScreen.module.css b/src/components/PRGradingScreen/PRGradingScreen.module.css
index adfff57b389..1687a588590 100644
--- a/src/components/PRGradingScreen/PRGradingScreen.module.css
+++ b/src/components/PRGradingScreen/PRGradingScreen.module.css
@@ -48,6 +48,23 @@
padding: 8px 20px;
}
+.pr-grading-screen-header-actions {
+ min-width: 0;
+ margin: 0;
+ padding: 0;
+ border: 0;
+}
+
+.pr-grading-screen-action-legend {
+ position: absolute;
+ width: 1px;
+ height: 1px;
+ padding: 0;
+ overflow: hidden;
+ clip-path: inset(50%);
+ white-space: nowrap;
+}
+
.pr-grading-screen-title {
font-size: 2rem;
font-weight: bold;
@@ -536,6 +553,12 @@
gap: 15px;
}
+ .pr-grading-screen-header-actions {
+ align-self: flex-end;
+ flex-wrap: wrap;
+ justify-content: flex-end;
+ }
+
.pr-grading-screen-header-right {
margin-left: 0;
align-self: flex-end;
@@ -700,6 +723,10 @@
color: #fff;
}
+.pr-grading-screen-table.dark-mode .pr-grading-screen-td-needed {
+ color: #fff;
+}
+
.pr-grading-screen-table.dark-mode .pr-grading-screen-pr-input {
background-color: #2d4059;
border-color: #5a6b88;
@@ -1157,7 +1184,8 @@
}
.pr-grading-inline-header-cell.dark-mode {
- color: #bbb;
+ background-color: #2d4059;
+ color: #fff;
border-bottom-color: rgb(255 255 255 / 10%);
}
@@ -1247,4 +1275,4 @@
padding: 2px 6px;
font-size: 12px;
}
-}
\ No newline at end of file
+}
diff --git a/src/components/PRGradingScreen/PRGradingView.jsx b/src/components/PRGradingScreen/PRGradingView.jsx
index 22a30e86789..6e045dd48cc 100644
--- a/src/components/PRGradingScreen/PRGradingView.jsx
+++ b/src/components/PRGradingScreen/PRGradingView.jsx
@@ -10,6 +10,7 @@ const PRGradingView = ({
teamData,
isFinalized,
onFinalize,
+ onExport,
searchTerm,
onSearchTermChange,
roleFilter,
@@ -34,7 +35,7 @@ const PRGradingView = ({
gradingReviewer,
onGradeChange,
}) => {
- const dm = darkMode ? 'dark-mode' : '';
+ const dm = darkMode ? styles['dark-mode'] : '';
return (
@@ -53,14 +54,22 @@ const PRGradingView = ({
-
+
@@ -391,6 +400,7 @@ PRGradingView.propTypes = {
}).isRequired,
isFinalized: PropTypes.bool.isRequired,
onFinalize: PropTypes.func.isRequired,
+ onExport: PropTypes.func.isRequired,
searchTerm: PropTypes.string.isRequired,
onSearchTermChange: PropTypes.func.isRequired,
roleFilter: PropTypes.string.isRequired,
diff --git a/src/components/PRGradingScreen/csvExport.js b/src/components/PRGradingScreen/csvExport.js
new file mode 100644
index 00000000000..0af47de0ebf
--- /dev/null
+++ b/src/components/PRGradingScreen/csvExport.js
@@ -0,0 +1,68 @@
+const CSV_HEADERS = [
+ 'Reviewer Name',
+ 'PR Reviewed Count',
+ 'PRs Needed',
+ 'PR Numbers',
+ 'Grades',
+ 'Notes',
+];
+
+const protectSpreadsheetValue = value => {
+ const text = String(value ?? '');
+ return /^[\t\r ]*[=+\-@]/.test(text) ? `'${text}` : text;
+};
+
+const escapeCsvValue = value => `"${protectSpreadsheetValue(value).replaceAll('"', '""')}"`;
+
+const getNotes = reviewer => {
+ const reviewerNotes = reviewer.notes ?? reviewer.note;
+ if (reviewerNotes) return reviewerNotes;
+
+ return reviewer.gradedPrs
+ .map(pr => pr.notes ?? pr.note)
+ .filter(Boolean)
+ .join('; ');
+};
+
+export const buildWeeklyPRGradingCsv = (reviewers, { useStoredCount = false } = {}) => {
+ const rows = reviewers.map(reviewer => [
+ reviewer.reviewer,
+ useStoredCount ? reviewer.prsReviewed ?? reviewer.gradedPrs.length : reviewer.gradedPrs.length,
+ reviewer.prsNeeded ?? '',
+ reviewer.gradedPrs.map(pr => pr.prNumbers).join('; '),
+ reviewer.gradedPrs.map(pr => pr.grade ?? '').join('; '),
+ getNotes(reviewer),
+ ]);
+
+ return [CSV_HEADERS, ...rows].map(row => row.map(escapeCsvValue).join(',')).join('\r\n');
+};
+
+const filenamePart = value =>
+ String(value)
+ .trim()
+ .toLowerCase()
+ .replace(/[^a-z0-9]+/g, '-')
+ .replace(/^-|-$/g, '');
+
+export const getWeeklyPRGradingFilename = dateRange => {
+ const start = filenamePart(dateRange.start);
+ const end = filenamePart(dateRange.end);
+ return `weekly-pr-grading-${start}-to-${end}.csv`;
+};
+
+export const downloadPRGradingCsv = (reviewers, filename, options = {}) => {
+ const csv = buildWeeklyPRGradingCsv(reviewers, options);
+ const blob = new Blob([`\uFEFF${csv}`], { type: 'text/csv;charset=utf-8' });
+ const url = URL.createObjectURL(blob);
+ const link = document.createElement('a');
+
+ link.href = url;
+ link.download = filename;
+ document.body.appendChild(link);
+ link.click();
+ link.remove();
+ setTimeout(() => URL.revokeObjectURL(url), 0);
+};
+
+export const downloadWeeklyPRGradingCsv = (reviewers, dateRange) =>
+ downloadPRGradingCsv(reviewers, getWeeklyPRGradingFilename(dateRange));
diff --git a/src/components/PRGradingScreen/csvExport.test.js b/src/components/PRGradingScreen/csvExport.test.js
new file mode 100644
index 00000000000..1bbaa243ef6
--- /dev/null
+++ b/src/components/PRGradingScreen/csvExport.test.js
@@ -0,0 +1,96 @@
+import { afterEach, describe, expect, it, vi } from 'vitest';
+import {
+ buildWeeklyPRGradingCsv,
+ downloadWeeklyPRGradingCsv,
+ getWeeklyPRGradingFilename,
+} from './csvExport';
+
+afterEach(() => {
+ vi.useRealTimers();
+ vi.unstubAllGlobals();
+ vi.restoreAllMocks();
+});
+
+describe('weekly PR grading CSV export', () => {
+ it('exports reviewer totals, PR numbers, aligned grades, and notes with CSV-safe escaping', () => {
+ const csv = buildWeeklyPRGradingCsv([
+ {
+ reviewer: 'Doe, Jane',
+ prsNeeded: 3,
+ notes: 'Reviewed "carefully"',
+ gradedPrs: [
+ { prNumbers: '123', grade: 'Okay' },
+ { prNumbers: '456 + 789', grade: 'Exceptional' },
+ ],
+ },
+ {
+ reviewer: '=HYPERLINK("bad")',
+ prsNeeded: 1,
+ gradedPrs: [{ prNumbers: '321', grade: 'Okay', note: 'Follow up' }],
+ },
+ ]);
+
+ expect(csv).toContain(
+ '"Reviewer Name","PR Reviewed Count","PRs Needed","PR Numbers","Grades","Notes"',
+ );
+ expect(csv).toContain(
+ '"Doe, Jane","2","3","123; 456 + 789","Okay; Exceptional","Reviewed ""carefully"""',
+ );
+ expect(csv).toContain('"\'=HYPERLINK(""bad"")","1","1","321","Okay","Follow up"');
+ });
+
+ it('keeps a blank grade position aligned with its PR number', () => {
+ const csv = buildWeeklyPRGradingCsv([
+ {
+ reviewer: 'Abi',
+ prsNeeded: 3,
+ gradedPrs: [
+ { prNumbers: '1000', grade: 'Exceptional' },
+ { prNumbers: '2000' },
+ { prNumbers: '3000', grade: 'Unsatisfactory' },
+ ],
+ },
+ ]);
+
+ expect(csv).toContain('"1000; 2000; 3000","Exceptional; ; Unsatisfactory"');
+ });
+
+ it('can export the dashboard count exactly as displayed, even with fewer listed PRs', () => {
+ const reviewers = [
+ {
+ reviewer: 'Alice',
+ prsReviewed: 7,
+ prsNeeded: 10,
+ gradedPrs: [{ prNumbers: '1070 + 1256', grade: 'Exceptional' }],
+ },
+ ];
+
+ expect(buildWeeklyPRGradingCsv(reviewers, { useStoredCount: true })).toContain(
+ '"Alice","7","10","1070 + 1256","Exceptional",""',
+ );
+ expect(buildWeeklyPRGradingCsv(reviewers)).toContain(
+ '"Alice","1","10","1070 + 1256","Exceptional",""',
+ );
+ });
+
+ it('creates a clear filename from the visible week date range', () => {
+ expect(getWeeklyPRGradingFilename({ start: 'Sep 21, 2026', end: 'Sep 27, 2026' })).toBe(
+ 'weekly-pr-grading-sep-21-2026-to-sep-27-2026.csv',
+ );
+ });
+
+ it('releases the temporary download URL after the click, not in the same tick', () => {
+ vi.useFakeTimers();
+ const createObjectURL = vi.fn(() => 'blob:weekly-grading');
+ const revokeObjectURL = vi.fn();
+ vi.stubGlobal('URL', { createObjectURL, revokeObjectURL });
+ const click = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {});
+
+ downloadWeeklyPRGradingCsv([], { start: '5/18/2025', end: '5/25/2025' });
+
+ expect(click).toHaveBeenCalledOnce();
+ expect(revokeObjectURL).not.toHaveBeenCalled();
+ vi.runAllTimers();
+ expect(revokeObjectURL).toHaveBeenCalledWith('blob:weekly-grading');
+ });
+});