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}

-
+
+ Review week actions +
-
+ @@ -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 = ({

- +
+ + Grading actions + + + +
@@ -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'); + }); +});