diff --git a/client/app/bundles/course/gradebook/__tests__/GradebookIndex.test.tsx b/client/app/bundles/course/gradebook/__tests__/GradebookIndex.test.tsx index c9f01f0dd0..e13cca92fd 100644 --- a/client/app/bundles/course/gradebook/__tests__/GradebookIndex.test.tsx +++ b/client/app/bundles/course/gradebook/__tests__/GradebookIndex.test.tsx @@ -1,3 +1,4 @@ +import { useLocation } from 'react-router-dom'; import { fireEvent, render, screen, waitFor, within } from 'test-utils'; import toast from 'lib/hooks/toast'; @@ -5,6 +6,13 @@ import toast from 'lib/hooks/toast'; import fetchGradebook from '../operations'; import GradebookIndex from '../pages/GradebookIndex'; +// TestApp mounts a MemoryRouter, whose location lives in memory and never +// touches window.location. This spy surfaces the router's current search +// string into the DOM so tests can assert on URL changes. +const LocationSearch = (): JSX.Element => ( +
{useLocation().search}
+); + jest.mock('../../container/CourseLoader', () => ({ useCourseContext: (): { courseTitle: string; id: number } => ({ courseTitle: 'Test Course', @@ -24,6 +32,8 @@ jest.mock('../operations', () => ({ const mockFetchGradebook = fetchGradebook as jest.Mock; +const WEIGHTED_TABLE_TESTID = 'gradebook-weighted-table'; + const emptyState = { gradebook: { categories: [], @@ -74,6 +84,16 @@ const populatedState = { }, }; +const studentsNoAssessmentsState = { + gradebook: { + ...populatedState.gradebook, + categories: [], + tabs: [], + assessments: [], + submissions: [], + }, +}; + const populatedStateWithGamification = { gradebook: { ...populatedState.gradebook, @@ -114,6 +134,55 @@ const populatedStateManagerWeightedOn = { }, }; +const populatedStateExternalInRange = { + gradebook: { + ...populatedState.gradebook, + assessments: [ + { + id: 200, + title: 'External Midterm', + tabId: 10, + maxGrade: 100, + external: true, + capAtMaximum: true, + floorAtZero: true, + }, + ], + submissions: [ + { studentId: 1, assessmentId: 200, submissionId: 200, grade: 90 }, // within [0,100] + ], + }, +}; + +const populatedStateWithOutOfRangeGrade = { + gradebook: { + ...populatedState.gradebook, + assessments: [ + { id: 100, title: 'Quiz 1', tabId: 10, maxGrade: 10 }, + { + id: 200, + title: 'External Midterm', + tabId: 10, + maxGrade: 100, + external: true, + capAtMaximum: true, + floorAtZero: true, + }, + ], + submissions: [ + { studentId: 1, assessmentId: 100, submissionId: 1000, grade: 8 }, + { studentId: 1, assessmentId: 200, submissionId: 200, grade: 110 }, // above max, capped + ], + }, +}; + +const populatedStateWithOutOfRangeGradeWeighted = { + gradebook: { + ...populatedStateWithOutOfRangeGrade.gradebook, + weightedViewEnabled: true, + }, +}; + beforeEach(() => { jest.clearAllMocks(); mockFetchGradebook.mockReturnValue((): Promise => Promise.resolve()); @@ -137,18 +206,44 @@ describe('GradebookIndex', () => { expect(await screen.findByText('Gradebook')).toBeInTheDocument(); }); - it('shows empty students message when there are no students', async () => { - render(, { state: noStudentsState }); + it('shows the grade-link hint in the all-assessments view', async () => { + render(, { state: populatedState }); expect( - await screen.findByText('No students enrolled yet'), + await screen.findByText( + /Click any grade to open that submission and adjust the marks/i, + ), ).toBeInTheDocument(); }); - it('shows empty students message when both assessments and students are absent', async () => { + it('hides the grade-link hint in the weighted-total view', async () => { + render(, { state: populatedStateWithWeightedView }); + const byWeightButton = await screen.findByText(/weighted total/i); + fireEvent.click(byWeightButton); + await screen.findByTestId(WEIGHTED_TABLE_TESTID); + expect( + screen.queryByText( + /Click any grade to open that submission and adjust the marks/i, + ), + ).not.toBeInTheDocument(); + }); + + it('shows empty students message and renders no gradebook table when there are no students', async () => { render(, { state: emptyState }); expect( await screen.findByText('No students enrolled yet'), ).toBeInTheDocument(); + expect(screen.queryByTestId(WEIGHTED_TABLE_TESTID)).not.toBeInTheDocument(); + }); + + it('renders the gradebook table when there are students but no assessments', async () => { + render(, { state: studentsNoAssessmentsState }); + expect( + await screen.findByRole('button', { name: /export/i }), + ).toBeInTheDocument(); + expect(screen.getByText('Alice')).toBeInTheDocument(); + expect( + screen.queryByText('No students enrolled yet'), + ).not.toBeInTheDocument(); }); it('shows error toast when fetch fails', async () => { @@ -171,7 +266,7 @@ describe('GradebookIndex', () => { ).toBeInTheDocument(); }); - it('shows grade-and-gamification hint in column picker when gamification is enabled and no data cols selected', async () => { + it('shows grade-and-gamification hint in column picker after enabling a gamification column with no grade columns selected', async () => { render(, { state: populatedStateWithGamification }); fireEvent.click( await screen.findByRole('button', { name: /select columns/i }), @@ -199,12 +294,38 @@ describe('GradebookIndex', () => { expect(await screen.findByText(/weighted total/i)).toBeInTheDocument(); }); - it('switches to Weighted total view on toggle click', async () => { - render(, { state: populatedStateWithWeightedView }); + it('switches to Weighted total view on toggle click and reflects it in the URL', async () => { + render( + <> + + + , + { state: populatedStateWithWeightedView }, + ); const byWeightButton = await screen.findByText(/weighted total/i); fireEvent.click(byWeightButton); expect( - await screen.findByTestId('gradebook-weighted-table'), + await screen.findByTestId(WEIGHTED_TABLE_TESTID), + ).toBeInTheDocument(); + expect(screen.getByTestId('location-search')).toHaveTextContent( + 'view=weighted', + ); + + fireEvent.click(await screen.findByText(/all assessments/i)); + await waitFor(() => + expect(screen.getByTestId('location-search')).not.toHaveTextContent( + 'view=weighted', + ), + ); + }); + + it('starts in Weighted total view when the URL requests it', async () => { + render(, { + state: populatedStateWithWeightedView, + at: ['/?view=weighted'], + }); + expect( + await screen.findByTestId(WEIGHTED_TABLE_TESTID), ).toBeInTheDocument(); }); @@ -214,7 +335,7 @@ describe('GradebookIndex', () => { }); const byWeightButton = await screen.findByText(/weighted total/i); fireEvent.click(byWeightButton); - await screen.findByTestId('gradebook-weighted-table'); + await screen.findByTestId(WEIGHTED_TABLE_TESTID); fireEvent.click( await screen.findByRole('button', { name: /select columns/i }), ); @@ -223,6 +344,75 @@ describe('GradebookIndex', () => { expect(within(dialog).queryByText('Total XP')).not.toBeInTheDocument(); }); + it('shows the manage button and not the old import/add buttons', async () => { + render(, { state: populatedStateManagerWeightedOff }); + expect( + await screen.findByRole('button', { + name: 'Manage external assessments', + }), + ).toBeVisible(); + expect( + screen.queryByRole('button', { name: 'Import external assessments' }), + ).not.toBeInTheDocument(); + expect( + screen.queryByRole('button', { name: 'Add external assessment' }), + ).not.toBeInTheDocument(); + }); + + it('shows the manage button in the weighted-total view for managers', async () => { + render(, { state: populatedStateManagerWeightedOn }); + const byWeightButton = await screen.findByText(/weighted total/i); + fireEvent.click(byWeightButton); + await screen.findByTestId(WEIGHTED_TABLE_TESTID); + expect( + screen.getByRole('button', { name: 'Manage external assessments' }), + ).toBeVisible(); + }); + + it('does not show the manage button to staff who cannot manage weights', async () => { + render(, { state: populatedState }); + await screen.findByRole('button', { name: /export/i }); // wait for load + expect( + screen.queryByRole('button', { name: 'Manage external assessments' }), + ).not.toBeInTheDocument(); + }); + + describe('out-of-range banner', () => { + it('shows the banner when there are out-of-range grades', async () => { + render(, { state: populatedStateWithOutOfRangeGrade }); + expect( + await screen.findByText(/outside their range/i), + ).toBeInTheDocument(); + }); + + it('shows the weighted-total wording when the weighted view is enabled', async () => { + render(, { + state: populatedStateWithOutOfRangeGradeWeighted, + }); + expect( + await screen.findByText( + /being capped or floored in the weighted total/i, + ), + ).toBeInTheDocument(); + }); + + it('does not show the banner when all grades are in range', async () => { + render(, { state: populatedStateExternalInRange }); + await screen.findByRole('button', { name: /export/i }); // wait for load + expect( + screen.queryByText(/outside their range/i), + ).not.toBeInTheDocument(); + }); + + it('does not show the banner when there are no students', async () => { + render(, { state: noStudentsState }); + await screen.findByText('No students enrolled yet'); + expect( + screen.queryByText(/outside their range/i), + ).not.toBeInTheDocument(); + }); + }); + describe('weighted-view discoverability hint', () => { it('shows the hint to managers when the weighted view is off', async () => { render(, { state: populatedStateManagerWeightedOff }); diff --git a/client/app/bundles/course/gradebook/__tests__/GradebookTable.test.tsx b/client/app/bundles/course/gradebook/__tests__/GradebookTable.test.tsx index 449fcc5a61..6c1249d202 100644 --- a/client/app/bundles/course/gradebook/__tests__/GradebookTable.test.tsx +++ b/client/app/bundles/course/gradebook/__tests__/GradebookTable.test.tsx @@ -2,7 +2,12 @@ import userEvent from '@testing-library/user-event'; import { store as appStore } from 'store'; import { render, screen, waitFor, within } from 'test-utils'; -import GradebookTable from '../components/GradebookTable'; +import CourseAPI from 'api/course'; +import toast from 'lib/hooks/toast'; + +import GradebookTable, { + EXTERNAL_ASSESSMENT_BACKGROUND, +} from '../components/GradebookTable'; import type { AssessmentData, CategoryData, @@ -11,6 +16,13 @@ import type { TabData, } from '../types'; +jest.mock('api/course'); + +jest.mock('lib/hooks/toast', () => ({ + __esModule: true, + default: { error: jest.fn(), success: jest.fn() }, +})); + const categories: CategoryData[] = [{ id: 1, title: 'Cat A' }]; const tabs: TabData[] = [{ id: 10, title: 'Tab 1', categoryId: 1 }]; const assessments: AssessmentData[] = [ @@ -79,10 +91,12 @@ const userState = { interface RenderOptions { gamificationEnabled?: boolean; + weightedViewEnabled?: boolean; } const renderTable = ({ gamificationEnabled = true, + weightedViewEnabled = false, }: RenderOptions = {}): void => { render( , { state: userState }, ); @@ -142,6 +157,7 @@ describe('GradebookTable', () => { students={students} submissions={submissions} tabs={tabs} + weightedViewEnabled={false} />, { state: userState }, ); @@ -226,6 +242,33 @@ describe('GradebookTable', () => { expect(await screen.findByText('Max Marks')).toBeInTheDocument(); }); + it('leaves the Max Marks cell blank under non-assessment columns', async () => { + localStorage.setItem( + STORAGE_KEY, + JSON.stringify({ name: true, email: true, 'asn-100': true }), + ); + renderTable(); + await screen.findByText('Max Marks'); + // The Max Marks (second header) row has a "/10" under Quiz 1 and the + // "Max Marks" label under name, but the email column's cell is empty. + // The Max Marks row lives in the TableHead, so its cells are + // (role columnheader), not /cell. + const maxMarksCell = screen.getByText('Max Marks').closest('th')!; + const maxMarksRow = maxMarksCell.closest('tr')!; + const cells = within(maxMarksRow as HTMLElement).getAllByRole( + 'columnheader', + ); + // checkbox spacer | name("Max Marks") | email("") | asn-100("/10") + expect(cells[2]).toHaveTextContent(''); + }); + + it('hides the Max Marks header row when all assessment columns are deselected', async () => { + localStorage.setItem(STORAGE_KEY, JSON.stringify({ 'asn-100': false })); + renderTable(); + await screen.findByText('Alice'); + expect(screen.queryByText('Max Marks')).not.toBeInTheDocument(); + }); + it('renders row selection checkboxes', async () => { renderTableWithAssessmentVisible(); await screen.findByText('Alice'); @@ -411,6 +454,36 @@ describe('GradebookTable', () => { }); }); + describe('no-data-columns hint', () => { + it('warns that export is student-info-only, mentioning gamification, when gamification is on', async () => { + const user = userEvent.setup(); + localStorage.setItem( + STORAGE_KEY, + JSON.stringify({ 'asn-100': false, level: false, totalXp: false }), + ); + renderTable({ gamificationEnabled: true }); + await screen.findByText('Alice'); + await user.click(screen.getByRole('button', { name: /select columns/i })); + expect( + await screen.findByText(/no grade or gamification columns selected/i), + ).toBeInTheDocument(); + }); + + it('warns that export is student-info-only, no gamification mention, when gamification is off', async () => { + const user = userEvent.setup(); + localStorage.setItem(STORAGE_KEY, JSON.stringify({ 'asn-100': false })); + renderTable({ gamificationEnabled: false }); + await screen.findByText('Alice'); + await user.click(screen.getByRole('button', { name: /select columns/i })); + expect( + await screen.findByText(/no grade columns selected/i), + ).toBeInTheDocument(); + expect( + screen.queryByText(/gamification columns selected/i), + ).not.toBeInTheDocument(); + }); + }); + describe('external ID column', () => { const studentsWithExtId: StudentData[] = [ { @@ -442,6 +515,7 @@ describe('GradebookTable', () => { students={studs} submissions={[]} tabs={tabs} + weightedViewEnabled={false} />, { state: userState }, ); @@ -514,6 +588,7 @@ describe('GradebookTable', () => { students={[students[1], students[0]]} submissions={submissions} tabs={tabs} + weightedViewEnabled={false} />, { state: userState }, ); @@ -572,6 +647,7 @@ describe('GradebookTable', () => { students={[students[1], students[0]]} // Bob first in raw order submissions={submissions} tabs={tabs} + weightedViewEnabled={false} />, { state: userState }, ); @@ -630,6 +706,7 @@ describe('GradebookTable', () => { students={[students[1], students[0]]} submissions={submissions} tabs={tabs} + weightedViewEnabled={false} />, { state: userState }, ); @@ -678,6 +755,7 @@ describe('GradebookTable', () => { students={studs} submissions={subs} tabs={tabs} + weightedViewEnabled={false} />, { state: userState }, ); @@ -701,9 +779,7 @@ describe('GradebookTable', () => { const user = userEvent.setup(); renderGrades(mixedStudents, mixedSubmissions); await screen.findByText('Alice'); - // Grade columns sort desc-first, so click twice to reach ascending. - await user.click(screen.getByRole('button', { name: /quiz 1/i })); // desc - await user.click(screen.getByRole('button', { name: /quiz 1/i })); // asc + await user.click(screen.getByRole('button', { name: /quiz 1/i })); // Ascending: Bob(3), Alice(8), then the two missing rows last. await waitFor(() => expectInOrder(['Bob', 'Alice'])); expectInOrder(['Alice', 'Carol']); @@ -714,7 +790,7 @@ describe('GradebookTable', () => { const user = userEvent.setup(); renderGrades(mixedStudents, mixedSubmissions); await screen.findByText('Alice'); - // Grade columns sort desc-first, so a single click yields descending. + await user.click(screen.getByRole('button', { name: /quiz 1/i })); // asc await user.click(screen.getByRole('button', { name: /quiz 1/i })); // desc // Descending: Alice(8), Bob(3), then the two missing rows still last. await waitFor(() => expectInOrder(['Alice', 'Bob'])); @@ -732,15 +808,549 @@ describe('GradebookTable', () => { ], ); await screen.findByText('Alice'); - // Grade columns sort desc-first, so click twice to reach ascending. - await user.click(screen.getByRole('button', { name: /quiz 1/i })); // desc - await user.click(screen.getByRole('button', { name: /quiz 1/i })); // asc + await user.click(screen.getByRole('button', { name: /quiz 1/i })); // Numeric ascending: 9 (Alice) before 10 (Bob). Lexical would reverse this. await waitFor(() => expectInOrder(['Alice', 'Bob'])); }); }); }); + describe('assessment grade cell rendering', () => { + const renderGradeCells = (subs: SubmissionData[]): void => { + localStorage.setItem( + STORAGE_KEY, + JSON.stringify({ name: true, 'asn-100': true }), + ); + render( + , + { state: userState }, + ); + }; + + it('renders "—" when the student has no submission for an assessment', async () => { + renderGradeCells([]); + await screen.findByText('Alice'); + const aliceRow = screen.getByText('Alice').closest('tr')!; + expect( + within(aliceRow as HTMLElement).getByText('—'), + ).toBeInTheDocument(); + }); + + it('renders an empty cell (not "—") for a submission with a null grade', async () => { + // Alice: submission with null grade → empty; Bob: no submission → '—' + renderGradeCells([ + { submissionId: 1, studentId: 1, assessmentId: 100, grade: null }, + ]); + await screen.findByText('Alice'); + const aliceRow = screen.getByText('Alice').closest('tr')!; + expect( + within(aliceRow as HTMLElement).queryByText('—'), + ).not.toBeInTheDocument(); + const bobRow = screen.getByText('Bob').closest('tr')!; + expect(within(bobRow as HTMLElement).getByText('—')).toBeInTheDocument(); + }); + + it('renders the grade as a link to the submission when submissionId is present', async () => { + renderGradeCells([ + { submissionId: 42, studentId: 1, assessmentId: 100, grade: 7 }, + ]); + await screen.findByText('Alice'); + const aliceRow = screen.getByText('Alice').closest('tr')!; + expect( + within(aliceRow as HTMLElement).getByRole('link', { name: '7' }), + ).toBeInTheDocument(); + }); + + it('renders the grade as plain text (no link) when there is no submissionId', async () => { + renderGradeCells([{ studentId: 1, assessmentId: 100, grade: 7 }]); + await screen.findByText('Alice'); + const aliceRow = screen.getByText('Alice').closest('tr')!; + expect( + within(aliceRow as HTMLElement).getByText('7'), + ).toBeInTheDocument(); + expect( + within(aliceRow as HTMLElement).queryByRole('link', { name: '7' }), + ).not.toBeInTheDocument(); + }); + }); + + describe('external assessment columns', () => { + const externalAssessments: AssessmentData[] = [ + { id: 100, title: 'Quiz 1', tabId: 10, maxGrade: 10 }, + { id: -5, title: 'Midterm', tabId: 200, maxGrade: 50, external: true }, + ]; + const externalTabs: TabData[] = [ + { id: 10, title: 'Tab 1', categoryId: 1 }, + { id: 200, title: 'Midterm', categoryId: 2 }, + ]; + const externalCategories: CategoryData[] = [ + { id: 1, title: 'Cat A' }, + { id: 2, title: 'External Assessments' }, + ]; + + const renderWithExternal = (): void => { + localStorage.setItem( + STORAGE_KEY, + JSON.stringify({ name: true, 'asn--5': true, 'asn-100': true }), + ); + render( + , + { state: userState }, + ); + }; + + it('renders the External badge in the external column header', async () => { + renderWithExternal(); + expect(await screen.findByText('External')).toBeVisible(); + }); + + it('tints the external assessment body cells with the external background', async () => { + renderWithExternal(); + const gradeCell = (await screen.findByText('30')).closest('td'); + expect(gradeCell).toHaveStyle({ + backgroundColor: EXTERNAL_ASSESSMENT_BACKGROUND, + }); + }); + + it('keeps the external column header the neutral grey (not the blue tint)', async () => { + renderWithExternal(); + const headerCell = (await screen.findByText('Midterm')).closest('th'); + // grey[100] — the same opaque neutral every other header cell uses, so the + // header reads as a header rather than a coloured band. + expect(headerCell).toHaveStyle({ backgroundColor: 'rgb(245, 245, 245)' }); + expect(headerCell).not.toHaveStyle({ + backgroundColor: EXTERNAL_ASSESSMENT_BACKGROUND, + }); + }); + + it('edits an external grade inline and persists optimistically', async () => { + (CourseAPI.gradebook.setExternalGrade as jest.Mock).mockResolvedValue({ + data: { studentId: 1, assessmentId: -5, grade: 45 }, + }); + renderWithExternal(); + const cell = await screen.findByText('30'); + await userEvent.click(cell); + const input = screen.getByRole('textbox', { + name: /midterm grade for alice/i, + }); + await userEvent.clear(input); + await userEvent.type(input, '45'); + await userEvent.tab(); + await waitFor(() => + expect(CourseAPI.gradebook.setExternalGrade).toHaveBeenCalledWith(5, { + studentId: 1, + grade: 45, + }), + ); + expect(await screen.findByText('45')).toBeVisible(); + }); + + it('rolls back the cell and keeps the old value when the API rejects', async () => { + (CourseAPI.gradebook.setExternalGrade as jest.Mock).mockRejectedValue( + new Error('boom'), + ); + renderWithExternal(); + const cell = await screen.findByText('30'); + await userEvent.click(cell); + const input = screen.getByRole('textbox', { + name: /midterm grade for alice/i, + }); + await userEvent.clear(input); + await userEvent.type(input, '45'); + await userEvent.tab(); + await waitFor(() => expect(screen.getByText('30')).toBeVisible()); + }); + + it('names the student and assessment in the failure toast', async () => { + (CourseAPI.gradebook.setExternalGrade as jest.Mock).mockRejectedValue( + new Error('boom'), + ); + renderWithExternal(); + const cell = await screen.findByText('30'); + await userEvent.click(cell); + const input = screen.getByRole('textbox', { + name: /midterm grade for alice/i, + }); + await userEvent.clear(input); + await userEvent.type(input, '45'); + await userEvent.tab(); + await waitFor(() => + expect(toast.error).toHaveBeenCalledWith( + expect.stringContaining('Midterm'), + ), + ); + expect(toast.error).toHaveBeenCalledWith( + expect.stringContaining('Alice'), + ); + }); + + it('confirms a successful edit with a persistent toast showing the student, assessment, and old → new grade', async () => { + (CourseAPI.gradebook.setExternalGrade as jest.Mock).mockResolvedValue({ + data: { studentId: 1, assessmentId: -5, grade: 45 }, + }); + (toast.success as jest.Mock).mockClear(); + renderWithExternal(); + const cell = await screen.findByText('30'); + await userEvent.click(cell); + const input = screen.getByRole('textbox', { + name: /midterm grade for alice/i, + }); + await userEvent.clear(input); + await userEvent.type(input, '45'); + await userEvent.tab(); + await waitFor(() => expect(toast.success).toHaveBeenCalled()); + const [message, options] = (toast.success as jest.Mock).mock.calls[0]; + expect(message).toEqual(expect.stringContaining('Midterm')); + expect(message).toEqual(expect.stringContaining('Alice')); + expect(message).toEqual(expect.stringContaining('30')); + expect(message).toEqual(expect.stringContaining('45')); + // Persistent (no auto-dismiss). The toast is the only in-session record of + // the overwritten value and exists to catch a row/column misclick, so it + // must wait for the user's attention rather than expire on a timer. + expect(options).toEqual(expect.objectContaining({ autoClose: false })); + }); + + it('does not toast a confirmation when the grade is unchanged', async () => { + (toast.success as jest.Mock).mockClear(); + (CourseAPI.gradebook.setExternalGrade as jest.Mock).mockClear(); + renderWithExternal(); + const cell = await screen.findByText('30'); + await userEvent.click(cell); + screen.getByRole('textbox', { name: /midterm grade for alice/i }); + // Commit without changing the value. + await userEvent.tab(); + expect(CourseAPI.gradebook.setExternalGrade).not.toHaveBeenCalled(); + expect(toast.success).not.toHaveBeenCalled(); + }); + + it('keeps regular assessment cells read-only (no input on click)', async () => { + renderWithExternal(); + // Alice has no Quiz 1 submission → '—' is rendered (not an ExternalGradeCell). + // Clicking the '—' in the Quiz 1 column must NOT produce a textbox. + await screen.findByText('Midterm'); // wait for render + const dashCells = screen.getAllByText('—'); + // The first '—' in Alice's row is the Quiz 1 cell (no submission). + await userEvent.click(dashCells[0]); + expect( + screen.queryByRole('textbox', { name: /quiz 1/i }), + ).not.toBeInTheDocument(); + }); + + it('renders the external chip without a manage menu', async () => { + renderWithExternal(); + expect(await screen.findByText('External')).toBeVisible(); + expect( + screen.queryByRole('button', { name: /manage/i }), + ).not.toBeInTheDocument(); + }); + + it('shows an in-flight spinner while the grade save is pending', async () => { + let resolveSave: () => void = () => {}; + (CourseAPI.gradebook.setExternalGrade as jest.Mock).mockReturnValue( + new Promise((resolve) => { + resolveSave = (): void => + resolve({ data: { studentId: 1, assessmentId: -5, grade: 45 } }); + }), + ); + renderWithExternal(); + const cell = await screen.findByText('30'); + await userEvent.click(cell); + const input = screen.getByRole('textbox', { + name: /midterm grade for alice/i, + }); + await userEvent.clear(input); + await userEvent.type(input, '45'); + await userEvent.tab(); + // Pending: spinner visible, optimistic value already shown. + expect(await screen.findByRole('progressbar')).toBeInTheDocument(); + resolveSave(); + // Resolved: spinner gone, value remains. + await waitFor(() => + expect(screen.queryByRole('progressbar')).not.toBeInTheDocument(), + ); + expect(screen.getByText('45')).toBeInTheDocument(); + }); + + it('caps an external grade at three integer digits (999.99 ceiling)', async () => { + renderWithExternal(); + const cell = await screen.findByText('30'); + await userEvent.click(cell); + const input = screen.getByRole('textbox', { + name: /midterm grade for alice/i, + }); + await userEvent.clear(input); + // The fourth integer digit is dropped at entry — the DB column maxes at 999.99. + await userEvent.type(input, '1000'); + expect(input).toHaveValue('100'); + }); + + it('accepts up to two decimal places and rejects a third', async () => { + renderWithExternal(); + const cell = await screen.findByText('30'); + await userEvent.click(cell); + const input = screen.getByRole('textbox', { + name: /midterm grade for alice/i, + }); + await userEvent.clear(input); + // A third decimal digit is rejected at entry — DB column is numeric(_, 2). + await userEvent.type(input, '12.345'); + expect(input).toHaveValue('12.34'); + }); + + it('does not call the API when the cell is committed without a change', async () => { + (CourseAPI.gradebook.setExternalGrade as jest.Mock).mockClear(); + renderWithExternal(); + const cell = await screen.findByText('30'); + await userEvent.click(cell); + // Blur without editing → commit() sees an unchanged value and returns early. + await userEvent.tab(); + await waitFor(() => expect(screen.getByText('30')).toBeVisible()); + expect(CourseAPI.gradebook.setExternalGrade).not.toHaveBeenCalled(); + }); + + const renderCapped = (grade: number): void => { + localStorage.setItem( + STORAGE_KEY, + JSON.stringify({ name: true, 'asn--5': true }), + ); + render( + , + { state: userState }, + ); + }; + + it('flags a grade above the maximum on a capped assessment', async () => { + renderCapped(60); + expect( + await screen.findByLabelText(/exceeds the maximum/i), + ).toBeInTheDocument(); + }); + + it('does not flag a grade within the maximum', async () => { + renderCapped(40); + await screen.findByText('40'); + expect( + screen.queryByLabelText(/exceeds the maximum/i), + ).not.toBeInTheDocument(); + }); + }); + + describe('ExternalGradeCell — negatives, buttons, tooltip copy', () => { + // External assessments use negative IDs on the frontend; the operation + // negates them before calling the API (so -(-901) = 901 is sent to the server). + const EXT_ASN_ID = -901; + const extAssessments: AssessmentData[] = [ + { + id: EXT_ASN_ID, + title: 'Midterms', + tabId: 300, + maxGrade: 100, + external: true, + floorAtZero: true, + capAtMaximum: true, + }, + ]; + const extTabs: TabData[] = [{ id: 300, title: 'Midterms', categoryId: 3 }]; + const extCategories: CategoryData[] = [ + { id: 3, title: 'External Assessments' }, + ]; + const aliceStudent: StudentData[] = [ + { + id: 1, + name: 'Alice', + email: 'alice@example.com', + externalId: null, + level: 1, + totalXp: 0, + }, + ]; + + beforeEach(() => { + (CourseAPI.gradebook.setExternalGrade as jest.Mock).mockClear(); + }); + + const renderForExternal = ({ + grade = null, + weightedViewEnabled = false, + }: { + grade?: number | null; + weightedViewEnabled?: boolean; + } = {}): ReturnType => { + localStorage.setItem( + STORAGE_KEY, + JSON.stringify({ name: true, [`asn-${EXT_ASN_ID}`]: true }), + ); + const subs = + grade !== null + ? [{ studentId: 1, assessmentId: EXT_ASN_ID, grade }] + : []; + return render( + , + { state: userState }, + ); + }; + + it('accepts a negative grade on entry and commits it', async () => { + (CourseAPI.gradebook.setExternalGrade as jest.Mock).mockResolvedValue({ + data: { studentId: 1, assessmentId: EXT_ASN_ID, grade: -5 }, + }); + renderForExternal({ grade: null }); + const cell = await screen.findByText('—'); + await userEvent.click(cell); + const input = screen.getByRole('textbox', { + name: /midterms grade for alice/i, + }); + await userEvent.clear(input); + await userEvent.type(input, '-5'); + await userEvent.keyboard('{Enter}'); + await waitFor(() => + expect(CourseAPI.gradebook.setExternalGrade).toHaveBeenCalledWith( + -EXT_ASN_ID, + { studentId: 1, grade: -5 }, + ), + ); + }); + + it('shows the below-zero icon when floored and grade < 0', async () => { + renderForExternal({ grade: -5, weightedViewEnabled: true }); + await screen.findByText('-5'); + expect(screen.getByLabelText(/below 0/i)).toBeInTheDocument(); + }); + + it('does not show the below-zero icon when the grade is non-negative', async () => { + renderForExternal({ grade: 5, weightedViewEnabled: true }); + await screen.findByText('5'); + expect(screen.queryByLabelText(/below 0/i)).not.toBeInTheDocument(); + }); + + it('revert button discards the edit without calling the API', async () => { + renderForExternal({ grade: 10 }); + const cell = await screen.findByText('10'); + await userEvent.click(cell); + const input = screen.getByRole('textbox', { + name: /midterms grade for alice/i, + }); + await userEvent.clear(input); + await userEvent.type(input, '20'); + // userEvent.pointer is used instead of userEvent.click so that the + // mousedown fires (and its preventDefault keeps input focus) before the + // click, mirroring real-browser behaviour and preventing the onBlur=commit + // from firing before the cancel handler runs. + const revertBtn = screen.getByRole('button', { name: /revert/i }); + await userEvent.pointer({ target: revertBtn, keys: '[MouseLeft]' }); + expect(CourseAPI.gradebook.setExternalGrade).not.toHaveBeenCalled(); + }); + + it('accept button commits the edit via the API', async () => { + (CourseAPI.gradebook.setExternalGrade as jest.Mock).mockResolvedValue({ + data: { studentId: 1, assessmentId: EXT_ASN_ID, grade: 20 }, + }); + renderForExternal({ grade: 10 }); + const cell = await screen.findByText('10'); + await userEvent.click(cell); + const input = screen.getByRole('textbox', { + name: /midterms grade for alice/i, + }); + await userEvent.clear(input); + await userEvent.type(input, '20'); + const acceptBtn = screen.getByRole('button', { name: /accept/i }); + await userEvent.click(acceptBtn); + await waitFor(() => + expect(CourseAPI.gradebook.setExternalGrade).toHaveBeenCalledWith( + -EXT_ASN_ID, + { studentId: 1, grade: 20 }, + ), + ); + }); + + it('over-max tooltip mentions the weighted total only when weighted view is on', async () => { + // Weighted: tooltip should mention "weighted total" + renderForExternal({ grade: 150, weightedViewEnabled: true }); + expect( + await screen.findByLabelText( + /contribution to the weighted total is capped/i, + ), + ).toBeInTheDocument(); + }); + + it('over-max tooltip does not mention weighted total when weighted view is off', async () => { + // Non-weighted: tooltip should just say "exceeds the maximum" without weighted mention + renderForExternal({ grade: 150, weightedViewEnabled: false }); + expect( + await screen.findByLabelText(/exceeds the maximum of 100/i), + ).toBeInTheDocument(); + expect( + screen.queryByLabelText(/weighted total/i), + ).not.toBeInTheDocument(); + }); + + it('below-zero tooltip mentions flooring in the weighted total when weighted view is on', async () => { + renderForExternal({ grade: -5, weightedViewEnabled: true }); + expect( + await screen.findByLabelText(/floored to 0 in the weighted total/i), + ).toBeInTheDocument(); + }); + + it('below-zero tooltip does not mention the weighted total when weighted view is off', async () => { + renderForExternal({ grade: -5, weightedViewEnabled: false }); + expect( + await screen.findByLabelText(/this grade is below 0\./i), + ).toBeInTheDocument(); + expect( + screen.queryByLabelText(/weighted total/i), + ).not.toBeInTheDocument(); + }); + }); + describe('cross-page selection', () => { it('export label reflects selection count across pages', async () => { const user = userEvent.setup(); @@ -763,6 +1373,7 @@ describe('GradebookTable', () => { students={makeStudents(101)} submissions={[]} tabs={tabs} + weightedViewEnabled={false} />, { state: userState }, ); @@ -790,4 +1401,36 @@ describe('GradebookTable', () => { ).toBeInTheDocument(); }); }); + + describe('external assessments', () => { + it('shows external assessment columns by default but keeps native ones hidden', async () => { + render( + , + { state: userState }, + ); + expect(await screen.findByText('Olympiad')).toBeInTheDocument(); + expect(screen.queryByText('Quiz 1')).not.toBeInTheDocument(); + }); + }); }); diff --git a/client/app/bundles/course/gradebook/__tests__/GradebookWeightedTable.test.tsx b/client/app/bundles/course/gradebook/__tests__/GradebookWeightedTable.test.tsx index c4bc96af0c..797a9b5ae0 100644 --- a/client/app/bundles/course/gradebook/__tests__/GradebookWeightedTable.test.tsx +++ b/client/app/bundles/course/gradebook/__tests__/GradebookWeightedTable.test.tsx @@ -82,6 +82,7 @@ interface RenderWeightedOptions { canManageWeights?: boolean; courseTitle?: string; courseId?: number; + toolbarAction?: JSX.Element; } const renderWeighted = ( @@ -104,6 +105,7 @@ const renderWeighted = ( students={students} submissions={submissions} tabs={tabs} + toolbarAction={opts.toolbarAction} />, { state: userState }, ); @@ -180,6 +182,19 @@ describe('GradebookWeightedTable', () => { expect(screen.getByText('/70')).toBeInTheDocument(); }); + // 3b. Weight subheader switches to "{weight}% of grade" in percent mode + it('shows "{weight}% of grade" subheader for each non-excluded tab in percent mode', async () => { + const user = userEvent.setup(); + renderWeighted({ + tabs: [makeTab(10, 'Tab 1', 1, 60), makeTab(11, 'Tab 2', 1, 40)], + }); + await user.click(screen.getByRole('radio', { name: /percentage/i })); + const thead = document.querySelector('thead')!; + const row3 = thead.querySelectorAll('tr')[2] as HTMLElement; + expect(within(row3).getByText('60% of grade')).toBeInTheDocument(); + expect(within(row3).getByText('40% of grade')).toBeInTheDocument(); + }); + // 4a. Total column shows "/100" when sum = 100 (points default) it('shows "/100" in total column header when weights sum to 100', () => { renderWeighted({ @@ -188,12 +203,14 @@ describe('GradebookWeightedTable', () => { expect(screen.getByText('/100')).toBeInTheDocument(); }); - // 4b. Total column shows just "/N" on one line when sum ≠ 100 - it('shows "/N" when weight sum ≠ 100 in total header', () => { + // 4b. Total column shows just "/N" on one line when sum ≠ 100 — the explanatory + // sentence is no longer an inline second line (it lives in the tooltip instead). + it('shows "/N" with no inline warning line when weight sum ≠ 100 in total header', () => { renderWeighted({ tabs: [makeTab(10, 'Tab 1', 1, 30), makeTab(11, 'Tab 2', 1, 30)], }); expect(screen.getByText('/60')).toBeInTheDocument(); + expect(screen.queryByText(/does not sum to 100/i)).not.toBeInTheDocument(); }); // 4c. Hovering the warning-coloured total reveals the full message via tooltip @@ -209,19 +226,19 @@ describe('GradebookWeightedTable', () => { ); }); - // 4b. Total column shows just "/N" on one line when sum ≠ 100 - it('shows "N% total" when sum ≠ 100 in total header', async () => { + // 4d. Percent mode, weights ≠ 100 → total header shows "{weight}% total" warning + it('total column header shows "{weight}% total" warning in percent mode when sum ≠ 100', async () => { const user = userEvent.setup(); renderWeighted({ - tabs: [makeTab(10, 'Tab 1', 1, 30), makeTab(11, 'Tab 2', 1, 30)], + tabs: [makeTab(10, 'Tab 1', 1, 60), makeTab(11, 'Tab 2', 1, 20)], }); await user.click(screen.getByRole('radio', { name: /percentage/i })); const thead = document.querySelector('thead')!; const row3 = thead.querySelectorAll('tr')[2] as HTMLElement; - expect(within(row3).getByText('60% total')).toBeInTheDocument(); + expect(within(row3).getByText('80% total')).toBeInTheDocument(); }); - // 4d. Percent mode, weights = 100 → total header shows exact "100% total" + // 4e. Percent mode, weights = 100 → total header shows exact "100% total" it('total column header shows "100% total" in percent mode when sum = 100', async () => { const user = userEvent.setup(); renderWeighted({ @@ -315,7 +332,7 @@ describe('GradebookWeightedTable', () => { expect(screen.getAllByText('100.00').length).toBeGreaterThanOrEqual(2); }); - // 6a. Tab with no assessments → cell shows "—" + // 6. Tab with no assessments → cell shows "—" it('shows "—" for a tab with no assessments', () => { renderWeighted({ tabs: [makeTab(10, 'Empty Tab', 1, 100)], @@ -415,6 +432,20 @@ describe('GradebookWeightedTable', () => { expect(screen.getByText(/set your own/i)).toBeInTheDocument(); }); + // 10e. Showing default weights suppresses the projected-total policy hint + it('does not show the projected-total policy banner when default weights are in effect', () => { + renderWeighted({ + tabs: [makeTab(10, 'Tab 1', 1, 0), makeTab(11, 'Tab 2', 1, 0)], + assessments: [ + makeAssessment(100, 'Quiz 1', 10, 150), + makeAssessment(101, 'Quiz 2', 11, 100), + ], + }); + expect( + screen.queryByText(/projected totals count ungraded assessments as 0/i), + ).not.toBeInTheDocument(); + }); + // 10a. Default split feeds the totals: two tabs → 50/100 each, summing to 100. it('applies an equal split (sums to 100) when no weights are configured', () => { renderWeighted({ @@ -449,6 +480,18 @@ describe('GradebookWeightedTable', () => { expect(screen.getByText(/no weights configured/i)).toBeInTheDocument(); }); + // 10f. Degenerate + canManageWeights=false → no-access copy + it('shows "No tab weights have been configured yet" when canManageWeights is false and no assessments', () => { + renderWeighted({ + tabs: [makeTab(10, 'Tab 1', 1, 0)], + assessments: [], + canManageWeights: false, + }); + expect( + screen.getByText(/no tab weights have been configured yet/i), + ).toBeInTheDocument(); + }); + // 10c. At least one non-zero weight → banner absent it('does not show empty-state banner when at least one tab has a non-zero weight', () => { renderWeighted({ @@ -521,26 +564,23 @@ describe('GradebookWeightedTable', () => { expect(screen.getByText('Alice')).toBeInTheDocument(); }); - // 14c. Typing an email filters student rows when Email column is visible + // 14c. Typing an email filters student rows (email column is searchable) it('filters student rows when typing an email in the search bar', async () => { const user = userEvent.setup(); - + // Search only matches visible columns ("search what you see"), and the + // Email column is hidden by default — surface it so the email is searchable. localStorage.setItem(WEIGHTED_STORAGE_KEY, JSON.stringify({ email: true })); - renderWeighted({ students: [makeStudent(1, 'Alice'), makeStudent(2, 'Bob')], }); - expect(screen.getByText('Alice')).toBeInTheDocument(); expect(screen.getByText('Bob')).toBeInTheDocument(); - expect(screen.getByText('alice@example.com')).toBeInTheDocument(); await user.type(screen.getByRole('textbox'), 'alice@example.com'); await waitFor(() => expect(screen.queryByText('Bob')).not.toBeInTheDocument(), ); - expect(screen.getByText('Alice')).toBeInTheDocument(); }); @@ -562,6 +602,39 @@ describe('GradebookWeightedTable', () => { expect(screen.getAllByRole('checkbox').length).toBeGreaterThanOrEqual(3); }); + describe('toolbar action slot', () => { + it('renders a provided toolbar action', () => { + renderWeighted({ + toolbarAction: , + }); + expect( + screen.getByRole('button', { name: 'Manage external' }), + ).toBeInTheDocument(); + }); + + it('places the toolbar action before the Select Columns button', () => { + renderWeighted({ + toolbarAction: , + }); + const action = screen.getByText('Manage external'); + const selectColumns = screen.getByRole('button', { + name: /select columns/i, + }); + expect( + // eslint-disable-next-line no-bitwise + action.compareDocumentPosition(selectColumns) & + Node.DOCUMENT_POSITION_FOLLOWING, + ).toBeTruthy(); + }); + + it('renders no extra action when toolbarAction is omitted', () => { + renderWeighted(); + expect( + screen.queryByRole('button', { name: 'Manage external' }), + ).not.toBeInTheDocument(); + }); + }); + describe('column picker', () => { it('shows Select Columns and Export buttons', () => { renderWeighted(); @@ -643,19 +716,6 @@ describe('GradebookWeightedTable', () => { ); }); - it('lists Email in the picker dialog (no gamification columns)', async () => { - const user = userEvent.setup(); - renderWeighted(); - await user.click(screen.getByRole('button', { name: /select columns/i })); - const dialog = await screen.findByRole('dialog'); - expect(within(dialog).getByText('Email')).toBeInTheDocument(); - expect( - within(dialog).queryByText('Gamification'), - ).not.toBeInTheDocument(); - expect(within(dialog).queryByText('Level')).not.toBeInTheDocument(); - expect(within(dialog).queryByText('Total XP')).not.toBeInTheDocument(); - }); - it('puts the select-all checkbox in an indeterminate state on a partial selection', async () => { const user = userEvent.setup(); renderWeighted({ @@ -668,6 +728,19 @@ describe('GradebookWeightedTable', () => { expect(checkboxes[0]).toHaveAttribute('data-indeterminate', 'true'), ); }); + + it('lists Email in the picker dialog (no gamification columns)', async () => { + const user = userEvent.setup(); + renderWeighted(); + await user.click(screen.getByRole('button', { name: /select columns/i })); + const dialog = await screen.findByRole('dialog'); + expect(within(dialog).getByText('Email')).toBeInTheDocument(); + expect( + within(dialog).queryByText('Gamification'), + ).not.toBeInTheDocument(); + expect(within(dialog).queryByText('Level')).not.toBeInTheDocument(); + expect(within(dialog).queryByText('Total XP')).not.toBeInTheDocument(); + }); }); describe('row-expand breakdown', () => { @@ -833,6 +906,27 @@ describe('GradebookWeightedTable', () => { ).toBeInTheDocument(); }); + it('rounds a fractional effective weightage to 2dp in the breakdown', async () => { + const user = userEvent.setup(); + // Tab weight 100 split equally across 3 assessments → 33.333…% → "33.33% of grade" + renderWeighted({ + tabs: [makeTab(10, 'Missions', 1, 100)], + assessments: [ + makeAssessment(1, 'M1', 10, 100), + makeAssessment(2, 'M2', 10, 100), + makeAssessment(3, 'M3', 10, 100), + ], + students: [makeStudent(1, 'Alice')], + submissions: [makeSub(1, 1, 50)], + }); + await user.click(screen.getByRole('button', { name: /expand Alice/i })); + expect( + within(await screen.findByTestId(breakdownRowId(1, 10, 1))).getByText( + /33\.33% of grade/, + ), + ).toBeInTheDocument(); + }); + it('shows effective weightage as "% of grade" regardless of the points/percentage lens', async () => { const user = userEvent.setup(); renderWeighted(expandable); @@ -928,30 +1022,9 @@ describe('GradebookWeightedTable', () => { ), ).toBeInTheDocument(); }); - - it('rounds a fractional effective weightage to 2dp in the breakdown', async () => { - const user = userEvent.setup(); - // Tab weight 100 split equally across 3 assessments → 33.333…% → "33.33% of grade" - renderWeighted({ - tabs: [makeTab(10, 'Missions', 1, 100)], - assessments: [ - makeAssessment(1, 'M1', 10, 100), - makeAssessment(2, 'M2', 10, 100), - makeAssessment(3, 'M3', 10, 100), - ], - students: [makeStudent(1, 'Alice')], - submissions: [makeSub(1, 1, 50)], - }); - await user.click(screen.getByRole('button', { name: /expand Alice/i })); - expect( - within(await screen.findByTestId(breakdownRowId(1, 10, 1))).getByText( - /33\.33% of grade/, - ), - ).toBeInTheDocument(); - }); }); - describe('display mode toggle — values', () => { + describe('display mode toggle - values', () => { // weight 100, one assessment max 100, grade 80 → subtotal 0.8 // points cell = 80 ; percent cell = 80% const singleTab = { @@ -992,6 +1065,18 @@ describe('GradebookWeightedTable', () => { expect(screen.getAllByText('80%').length).toBeGreaterThanOrEqual(1), ); }); + }); + + describe('display mode toggle - control', () => { + it('renders Points and Percentage toggle buttons with Points pressed by default', () => { + renderWeighted(); + const points = screen.getByRole('radio', { name: /points/i }); + const percent = screen.getByRole('radio', { name: /percentage/i }); + expect(points).toBeInTheDocument(); + expect(percent).toBeInTheDocument(); + expect(points).toHaveAttribute('aria-checked', 'true'); + expect(percent).toHaveAttribute('aria-checked', 'false'); + }); it('shows the Points and Percentage explanatory tooltips on hover', async () => { renderWeighted(); @@ -1010,18 +1095,6 @@ describe('GradebookWeightedTable', () => { }); }); - describe('display mode toggle — control', () => { - it('renders Points and Percentage toggle buttons with Points pressed by default', () => { - renderWeighted(); - const points = screen.getByRole('radio', { name: /points/i }); - const percent = screen.getByRole('radio', { name: /percentage/i }); - expect(points).toBeInTheDocument(); - expect(percent).toBeInTheDocument(); - expect(points).toHaveAttribute('aria-checked', 'true'); - expect(percent).toHaveAttribute('aria-checked', 'false'); - }); - }); - describe('identity columns rendering', () => { it('hides Email and External ID by default', () => { renderWeighted(); @@ -1074,6 +1147,7 @@ describe('GradebookWeightedTable', () => { within(thead as HTMLElement).getByText(EXTERNAL_ID), ).toBeInTheDocument(); expect(screen.getByText('EXT-001')).toBeInTheDocument(); + // Bob has no external ID → his External ID cell renders empty, not "null". const bobRow = screen.getByText('Bob').closest('tr')!; expect(within(bobRow).queryByText('null')).not.toBeInTheDocument(); @@ -1259,4 +1333,122 @@ describe('GradebookWeightedTable', () => { expect(cells[cells.length - 1]).toHaveTextContent('—'); }); }); + + describe('external assessment regression', () => { + it('includes an external assessment in its tab subtotal and the projected total', () => { + // Regular tab (weight 50): Quiz id=100, max=10, Alice grade=8 + // subtotal = 8/10 = 0.8; cell = 0.8 × 50 = 40 + // External tab (weight 50): Midterm id=-5, max=50, Alice grade=25 + // subtotal = 25/50 = 0.5; cell = 0.5 × 50 = 25 + // Total = 40 + 25 = 65 + renderWeighted({ + categories: [ + makeCategory(1, 'Cat A'), + makeCategory(2, 'External Assessments'), + ], + tabs: [makeTab(10, 'Tab 1', 1, 50), makeTab(200, 'Midterm', 2, 50)], + assessments: [ + makeAssessment(100, 'Quiz 1', 10, 10), + { + id: -5, + title: 'Midterm', + tabId: 200, + maxGrade: 50, + external: true, + }, + ], + students: [makeStudent(1, 'Alice')], + submissions: [makeSub(1, 100, 8), makeSub(1, -5, 25)], + }); + + // External Assessments category and its tab must appear in the header + expect(screen.getByText('External Assessments')).toBeInTheDocument(); + expect(screen.getByText('Midterm')).toBeInTheDocument(); + + // External tab cell = 25 (integer); regular tab cell = 40; total = 65 + const cells = screen.getAllByRole('cell'); + const cellTexts = cells.map((c) => c.textContent?.trim()); + expect(cellTexts).toContain('40'); + expect(cellTexts).toContain('25'); + expect(cellTexts).toContain('65'); + }); + }); + + describe('clamped grade indicator in breakdown', () => { + it('marks a capped grade in the breakdown', async () => { + const user = userEvent.setup(); + // Render with an external assessment: maxGrade 100, capAtMaximum true, + // and a student grade of 150. Expand the student's breakdown row. + renderWeighted({ + tabs: [makeTab(10, 'Tab 1', 1, 100)], + assessments: [ + { + id: -5, + title: 'External Test', + tabId: 10, + maxGrade: 100, + external: true, + capAtMaximum: true, + }, + ], + students: [makeStudent(1, 'Alice')], + submissions: [makeSub(1, -5, 150)], + }); + await user.click(screen.getByRole('button', { name: /expand Alice/i })); + // Raw grade shown in the breakdown row subtitle (e.g., "150/100") + expect(await screen.findByText(/150\/100/)).toBeInTheDocument(); + // Capped indicator visible with aria-label + expect(screen.getByLabelText('Capped at 100')).toBeInTheDocument(); + }); + + it('marks a floored grade in the breakdown', async () => { + const user = userEvent.setup(); + // Render with an external assessment: maxGrade 100, floorAtZero true, + // and a student grade of -10. Expand the student's breakdown row. + renderWeighted({ + tabs: [makeTab(10, 'Tab 1', 1, 100)], + assessments: [ + { + id: -6, + title: 'External Test', + tabId: 10, + maxGrade: 100, + external: true, + floorAtZero: true, + }, + ], + students: [makeStudent(1, 'Alice')], + submissions: [makeSub(1, -6, -10)], + }); + await user.click(screen.getByRole('button', { name: /expand Alice/i })); + // Raw grade shown in the breakdown row subtitle (e.g., "-10/100") + expect(await screen.findByText(/-10\/100/)).toBeInTheDocument(); + // Floored indicator visible with aria-label + expect(screen.getByLabelText('Floored to 0')).toBeInTheDocument(); + }); + + it('shows the "Counts as" effective-value tooltip on the clamped indicator', async () => { + const user = userEvent.setup(); + renderWeighted({ + tabs: [makeTab(10, 'Tab 1', 1, 100)], + assessments: [ + { + id: -5, + title: 'External Test', + tabId: 10, + maxGrade: 100, + external: true, + capAtMaximum: true, + }, + ], + students: [makeStudent(1, 'Alice')], + submissions: [makeSub(1, -5, 150)], + }); + await user.click(screen.getByRole('button', { name: /expand Alice/i })); + await userEvent.hover(await screen.findByLabelText('Capped at 100')); + await waitFor(() => + expect(screen.getByText('Counts as 100')).toBeInTheDocument(), + ); + }); + }); }); diff --git a/client/app/bundles/course/gradebook/__tests__/OutOfRangeAlert.test.tsx b/client/app/bundles/course/gradebook/__tests__/OutOfRangeAlert.test.tsx new file mode 100644 index 0000000000..3cfdec7c06 --- /dev/null +++ b/client/app/bundles/course/gradebook/__tests__/OutOfRangeAlert.test.tsx @@ -0,0 +1,119 @@ +import { render, screen } from 'test-utils'; + +import OutOfRangeAlert from '../components/OutOfRangeAlert'; + +describe('OutOfRangeAlert', () => { + it('renders nothing when there are no out-of-range grades', async () => { + render( + , + ); + + expect(screen.queryByRole('alert')).not.toBeInTheDocument(); + }); + + it('summarises counts, assessment names, and prompts review before export', async () => { + render( + , + ); + + expect( + await screen.findByText( + /3 grades in the external assessments External Quiz and Final Exam/i, + ), + ).toBeInTheDocument(); + + expect(screen.getByText(/review before exporting/i)).toBeInTheDocument(); + }); + + it('joins three or more assessment names with commas and a final "and"', async () => { + render( + , + ); + + expect( + await screen.findByText(/Quiz A, Quiz B and Quiz C/i), + ).toBeInTheDocument(); + }); + + it('uses singular grade/assessment wording and a bare name for one grade', async () => { + render( + , + ); + + expect( + await screen.findByText( + /1 grade in the external assessment External Quiz is outside their range/i, + ), + ).toBeInTheDocument(); + }); + + it('names the weighted-total capping/flooring when the weighted view is on', async () => { + render( + , + ); + + expect( + await screen.findByText(/being capped or floored in the weighted total/i), + ).toBeInTheDocument(); + }); + + it('omits the weighted-total clause when the weighted view is off', async () => { + render( + , + ); + + expect(await screen.findByRole('alert')).toBeInTheDocument(); + expect( + screen.queryByText(/being capped or floored in the weighted total/i), + ).not.toBeInTheDocument(); + }); + + it('does not mention the weighted total when weighting is off', async () => { + render( + , + ); + const alert = await screen.findByRole('alert'); + expect(alert.textContent).not.toMatch(/weighted total/i); + expect(alert.textContent).not.toMatch(/capped or floored/i); + }); + + it('mentions the weighted total when weighting is on', async () => { + render( + , + ); + const alert = await screen.findByRole('alert'); + expect(alert.textContent).toMatch(/weighted total/i); + }); +}); diff --git a/client/app/bundles/course/gradebook/__tests__/outOfRange.test.ts b/client/app/bundles/course/gradebook/__tests__/outOfRange.test.ts new file mode 100644 index 0000000000..6ca4740ff4 --- /dev/null +++ b/client/app/bundles/course/gradebook/__tests__/outOfRange.test.ts @@ -0,0 +1,89 @@ +import type { AssessmentData, SubmissionData } from 'types/course/gradebook'; + +import { outOfRangeSummary } from '../outOfRange'; + +const MIDTERMS = 'Midterms'; + +const ext = (over: Partial): AssessmentData => ({ + id: 1, + title: MIDTERMS, + tabId: -1, + maxGrade: 100, + external: true, + floorAtZero: true, + capAtMaximum: true, + ...over, +}); +const sub = (assessmentId: number, grade: number | null): SubmissionData => ({ + studentId: 1, + assessmentId, + grade, +}); + +describe('outOfRangeSummary', () => { + it('counts grades above max only when capped, below 0 only when floored', () => { + const assessments = [ + ext({ id: 1, capAtMaximum: true, floorAtZero: true }), + ext({ id: 2, capAtMaximum: false, floorAtZero: false, maxGrade: 50 }), + ]; + const submissions = [ + sub(1, 105), // counted (capped) + sub(1, -3), // counted (floored) + sub(2, 999), // NOT counted (cap off) + sub(2, -9), // NOT counted (floor off) + ]; + expect(outOfRangeSummary(assessments, submissions)).toEqual({ + gradeCount: 2, + assessmentNames: [MIDTERMS], + }); + }); + + it('counts each offending grade but de-dups assessment names by id', () => { + const assessments = [ + ext({ id: 1, title: 'Quiz A', maxGrade: 100 }), + ext({ id: 2, title: 'Quiz B', maxGrade: 100 }), + ]; + const submissions = [ + sub(1, 105), // over + sub(1, -1), // under (same assessment) + sub(2, 200), // over (different assessment) + ]; + expect(outOfRangeSummary(assessments, submissions)).toEqual({ + gradeCount: 3, + assessmentNames: ['Quiz A', 'Quiz B'], + }); + }); + + it('treats grade exactly at maxGrade as in range when capped', () => { + expect( + outOfRangeSummary([ext({ id: 1, maxGrade: 100 })], [sub(1, 100)]), + ).toEqual({ gradeCount: 0, assessmentNames: [] }); + }); + + it('treats grade exactly at 0 as in range when floored', () => { + expect( + outOfRangeSummary([ext({ id: 1, floorAtZero: true })], [sub(1, 0)]), + ).toEqual({ gradeCount: 0, assessmentNames: [] }); + }); + + it('ignores null grades and in-range grades', () => { + expect( + outOfRangeSummary([ext({ id: 1 })], [sub(1, null), sub(1, 50)]), + ).toEqual({ gradeCount: 0, assessmentNames: [] }); + }); + + it('ignores native (non-external) assessments', () => { + const native = ext({ id: 9, external: false }); + expect(outOfRangeSummary([native], [sub(9, 999)])).toEqual({ + gradeCount: 0, + assessmentNames: [], + }); + }); + + it('ignores submissions with no matching assessment', () => { + expect(outOfRangeSummary([ext({ id: 1 })], [sub(404, 999)])).toEqual({ + gradeCount: 0, + assessmentNames: [], + }); + }); +}); diff --git a/client/app/bundles/course/gradebook/components/GradebookTable.tsx b/client/app/bundles/course/gradebook/components/GradebookTable.tsx index dc430d3a8d..205f148dc1 100644 --- a/client/app/bundles/course/gradebook/components/GradebookTable.tsx +++ b/client/app/bundles/course/gradebook/components/GradebookTable.tsx @@ -8,9 +8,14 @@ import { useState, } from 'react'; import { defineMessages } from 'react-intl'; +import Check from '@mui/icons-material/Check'; +import Close from '@mui/icons-material/Close'; +import InfoOutlined from '@mui/icons-material/InfoOutlined'; import { Checkbox, Chip, + CircularProgress, + IconButton, Paper, type SxProps, Table, @@ -83,6 +88,20 @@ const getColWidth = (id: string): number => const isLeftAligned = (id: string): boolean => id === 'name' || id === 'email' || id === 'externalId'; +// Resolves a column's stable key. TanStack columns identify by `id`, falling back +// to `of` for plain accessor columns; this single helper keeps that rule in one +// place instead of repeating the `??` across every column loop. +const colKey = (c: ColumnTemplate): string => + c.id ?? (c.of as string); + +// The two grey separator weights used across the table. `gridLine` is the hairline +// cell grid; `seamLine` is the heavier 1px edge that bounds the frozen header/column +// block (a full pixel survives sticky-scroll compositing where 0.5px can drop out). +const gridLine = (theme: Theme): string => + `0.5px solid ${theme.palette.grey[200]}`; +const seamLine = (theme: Theme): string => + `1px solid ${theme.palette.grey[200]}`; + const translations = defineMessages({ searchStudents: { id: 'course.gradebook.GradebookIndex.searchStudents', @@ -132,12 +151,39 @@ const translations = defineMessages({ }, gradeSaveError: { id: 'course.gradebook.GradebookTable.gradeSaveError', - defaultMessage: 'Could not save the grade. Please try again.', + defaultMessage: + 'Could not save the {title} grade for {name}. Please try again.', }, gradeSaved: { id: 'course.gradebook.GradebookTable.gradeSaved', defaultMessage: 'Grade saved. {title} · {name}: {oldGrade} → {newGrade}', }, + gradeExceedsMax: { + id: 'course.gradebook.GradebookTable.gradeExceedsMax', + defaultMessage: 'This grade exceeds the maximum of {max}.', + }, + gradeExceedsMaxWeighted: { + id: 'course.gradebook.GradebookTable.gradeExceedsMaxWeighted', + defaultMessage: + 'This grade exceeds the maximum of {max}; its contribution to the weighted total is capped at {max}.', + }, + gradeBelowZero: { + id: 'course.gradebook.GradebookTable.gradeBelowZero', + defaultMessage: 'This grade is below 0.', + }, + gradeBelowZeroWeighted: { + id: 'course.gradebook.GradebookTable.gradeBelowZeroWeighted', + defaultMessage: + 'This grade is below 0; it is floored to 0 in the weighted total.', + }, + acceptEdit: { + id: 'course.gradebook.GradebookTable.acceptEdit', + defaultMessage: 'Accept', + }, + revertEdit: { + id: 'course.gradebook.GradebookTable.revertEdit', + defaultMessage: 'Revert', + }, }); const HeaderLabel = forwardRef< @@ -207,16 +253,24 @@ HeaderLabel.displayName = 'HeaderLabel'; const ExternalGradeCell = ({ assessmentId, + capAtMaximum, + floorAtZero, + maxGrade, studentId, studentName, title, value, + weightedViewEnabled, }: { assessmentId: number; + capAtMaximum: boolean; + floorAtZero: boolean; + maxGrade: number; studentId: number; studentName: string; title: string; value: number | null | undefined; + weightedViewEnabled: boolean; }): JSX.Element => { const { t } = useTranslation(); const dispatch = useAppDispatch(); @@ -225,6 +279,7 @@ const ExternalGradeCell = ({ const [localValue, setLocalValue] = useState( value, ); + const [saving, setSaving] = useState(false); const commit = async (): Promise => { setEditing(false); @@ -234,6 +289,7 @@ const ExternalGradeCell = ({ if (next === (localValue ?? null)) return; const prev = localValue; setLocalValue(next); + setSaving(true); try { await dispatch(setExternalGrade(assessmentId, studentId, next)); // Persistent confirmation: external grades flow to exported finals, and the @@ -252,31 +308,89 @@ const ExternalGradeCell = ({ ); } catch { setLocalValue(prev); - toast.error(t(translations.gradeSaveError)); + toast.error(t(translations.gradeSaveError, { name: studentName, title })); + } finally { + setSaving(false); } }; + const cancel = (): void => setEditing(false); + + const exceedsMax = + capAtMaximum && localValue != null && localValue > maxGrade; + const belowZero = floorAtZero && localValue != null && localValue < 0; + + const exceedsMsg = weightedViewEnabled + ? translations.gradeExceedsMaxWeighted + : translations.gradeExceedsMax; + const belowMsg = weightedViewEnabled + ? translations.gradeBelowZeroWeighted + : translations.gradeBelowZero; + if (editing) { return ( - setText(e.target.value)} - onKeyDown={(e) => { - if (e.key === 'Enter') commit(); - if (e.key === 'Escape') setEditing(false); + + > + { + const next = e.target.value; + if (next === '' || /^-?\d{0,3}(\.\d{0,2})?$/.test(next)) + setText(next); + }} + onKeyDown={(e) => { + if (e.key === 'Enter') commit(); + if (e.key === 'Escape') cancel(); + }} + size="small" + sx={{ + width: 72, + '& .MuiInputBase-root': { + width: 72, + }, + }} + value={text} + variant="standard" + /> + {/* onMouseDown preventDefault keeps focus so the input's onBlur=commit + does not fire before onClick — critical for Revert (a blur-commit + would save the very edit we are discarding). */} + e.preventDefault()} + size="small" + sx={{ flexShrink: 0 }} + > + + + e.preventDefault()} + size="small" + sx={{ flexShrink: 0 }} + > + + + ); } @@ -287,10 +401,37 @@ const ExternalGradeCell = ({ setEditing(true); }} role="button" - style={{ cursor: 'pointer', display: 'inline-block', minWidth: 24 }} + style={{ + cursor: 'pointer', + display: 'flex', + alignItems: 'center', + justifyContent: 'flex-end', + gap: 4, + width: '100%', + height: '100%', + }} tabIndex={0} > - {localValue == null ? '—' : localValue} + {exceedsMax && ( + + + + )} + {belowZero && ( + + + + )} + {saving && } + {localValue == null ? '—' : localValue} ); }; @@ -315,6 +456,7 @@ interface GradebookTableProps { courseTitle: string; courseId: number; gamificationEnabled: boolean; + weightedViewEnabled: boolean; /** Optional action rendered in the toolbar, left of the column picker. */ toolbarAction?: JSX.Element; } @@ -328,6 +470,7 @@ const GradebookTable = ({ courseTitle, courseId, gamificationEnabled, + weightedViewEnabled, toolbarAction, }: GradebookTableProps): JSX.Element => { const { t } = useTranslation(); @@ -442,6 +585,7 @@ const GradebookTable = ({ sortable: true, sortProps: { undefinedPriority: 'last', + descFirst: false, sort: (a, b) => { const aGrade = a.grades[asn.id]; const bGrade = b.grades[asn.id]; @@ -454,10 +598,14 @@ const GradebookTable = ({ return ( ); } @@ -474,11 +622,17 @@ const GradebookTable = ({ return grade; }, csvDownloadable: true, - defaultVisible: false, + defaultVisible: asn.external ?? false, }); }); return cols; - }, [assessments, gamificationEnabled, hasExternalIds, t]); + }, [ + assessments, + gamificationEnabled, + hasExternalIds, + t, + weightedViewEnabled, + ]); const assessmentMaxGrades = useMemo( () => new Map(assessments.map((a) => [a.id, a.maxGrade])), @@ -583,9 +737,7 @@ const GradebookTable = ({ const visibility = toolbar?.getColumnVisibility?.() ?? {}; const isColVisible = (id: string): boolean => visibility[id] ?? true; - const visibleCols = columns.filter((c) => - isColVisible(c.id ?? (c.of as string)), - ); + const visibleCols = columns.filter((c) => isColVisible(colKey(c))); const sortByColId = new Map( (header?.headers ?? []).map( @@ -627,10 +779,7 @@ const GradebookTable = ({ const totalWidth = useMemo( () => CHECKBOX_WIDTH + - visibleCols.reduce((sum, c) => { - const id = c.id ?? (c.of as string); - return sum + getColWidth(id); - }, 0), + visibleCols.reduce((sum, c) => sum + getColWidth(colKey(c)), 0), [visibleCols], ); @@ -639,10 +788,7 @@ const GradebookTable = ({ const toggleAllRows = (): void => body.toggleAllFiltered?.(); const hasVisibleAssessments = useMemo( - () => - visibleCols.some( - (c) => parseAssessmentColumnId(c.id ?? (c.of as string)) !== null, - ), + () => visibleCols.some((c) => parseAssessmentColumnId(colKey(c)) !== null), [visibleCols], ); @@ -664,7 +810,7 @@ const GradebookTable = ({ () => new Map( visibleCols.map((c) => { - const id = c.id ?? (c.of as string); + const id = colKey(c); return [id, (f: boolean): void => onSingleLine(id, f)]; }), ), @@ -699,14 +845,14 @@ const GradebookTable = ({ border: 0, // Draws the cell grid without relying on collapsed borders. - borderBottom: `0.5px solid ${theme.palette.grey[200]}`, + borderBottom: gridLine(theme), }, })} > {visibleCols.map((c) => { - const id = c.id ?? (c.of as string); + const id = colKey(c); return ; })} @@ -732,7 +878,7 @@ const GradebookTable = ({ // compositing where 0.5px can drop and let the body show // through the row1/row2 seam. '&&': { - borderBottom: `1px solid ${theme.palette.grey[200]}`, + borderBottom: seamLine(theme), }, })} > @@ -744,13 +890,36 @@ const GradebookTable = ({ /> {visibleCols.map((c) => { - const id = c.id ?? (c.of as string); + const id = colKey(c); const label = typeof c.title === 'string' ? c.title : id; const isLeft = isLeftAligned(id); const fits = headerFits[id] ?? false; const sort = sortByColId.get(id); const isExternalCol = externalAssessmentColumnIds.has(id); - const labelNode = ( + // Wrap a header label in the clickable sort control (or leave + // it bare when the column isn't sortable). `extraSx` lets the + // external variant flip the sort arrow to the label's left. + const withSort = ( + inner: JSX.Element, + extraSx?: Record, + ): JSX.Element => + sort ? ( + + {inner} + + ) : ( + inner + ); + const sortedLabel = withSort( - + , ); - const sortedLabel = sort ? ( - - {labelNode} - - ) : ( - labelNode + const externalSortedLabel = withSort( + + + {label} + + , + { + display: 'inline-flex', + flexDirection: 'row-reverse', + justifyContent: 'flex-start', + }, ); return ( {isExternalCol ? ( - {sortedLabel} + {externalSortedLabel} ) : ( @@ -824,8 +1013,8 @@ const GradebookTable = ({ // body never shows through on scroll. `& .MuiTableCell-root` // (0,2,0) outranks the table's `& th` rule (0,1,1). '& .MuiTableCell-root': { - borderTop: `1px solid ${theme.palette.grey[200]}`, - borderBottom: `1px solid ${theme.palette.grey[200]}`, + borderTop: seamLine(theme), + borderBottom: seamLine(theme), }, })} > @@ -843,7 +1032,7 @@ const GradebookTable = ({ }} /> {visibleCols.map((c) => { - const id = c.id ?? (c.of as string); + const id = colKey(c); const asnId = parseAssessmentColumnId(id); let cellNode: React.ReactNode = ''; if (id === 'name') cellNode = t(translations.maxMarks); @@ -863,8 +1052,8 @@ const GradebookTable = ({ zIndex: 3, // Continue the frozen region's right edge. '&&': { - borderTop: `1px solid ${theme.palette.grey[200]}`, - borderRight: `1px solid ${theme.palette.grey[200]}`, + borderTop: seamLine(theme), + borderRight: seamLine(theme), }, }), })} @@ -903,10 +1092,7 @@ const GradebookTable = ({ // layer and always paints. Row 0's top edge is already // the header cell's (higher z-index) bottom border. borderBottom: 'none', - borderTop: - idx === 0 - ? undefined - : `0.5px solid ${theme.palette.grey[200]}`, + borderTop: idx === 0 ? undefined : gridLine(theme), })} > cell.column.id !== 'rowSelector') .map((cell) => { + const isExternalCol = externalAssessmentColumnIds.has( + cell.column.id, + ); // Sticky cover for the frozen `name` column, mirroring // the checkbox cell above. Declared as a directly-typed // const so the callback is contextually typed (a ternary @@ -932,22 +1121,17 @@ const GradebookTable = ({ // the separator as the lower row's `borderTop`, not a // covered `borderBottom`. borderBottom: 'none', - borderTop: - idx === 0 - ? undefined - : `0.5px solid ${theme.palette.grey[200]}`, + borderTop: idx === 0 ? undefined : gridLine(theme), // Continue the frozen region's right edge down the data // rows. `&&` (0,2,0) beats the table's `& td` border // rule (0,1,1). '&&': { - borderRight: `1px solid ${theme.palette.grey[200]}`, + borderRight: seamLine(theme), }, }); let cellSx: SxProps | undefined; if (cell.column.id === 'name') cellSx = nameCellSx; - else if ( - externalAssessmentColumnIds.has(cell.column.id) - ) + else if (isExternalCol) // bgcolor is the faint external-column tint. cellSx = { bgcolor: EXTERNAL_ASSESSMENT_BACKGROUND, diff --git a/client/app/bundles/course/gradebook/components/GradebookWeightedTable.tsx b/client/app/bundles/course/gradebook/components/GradebookWeightedTable.tsx index 69a461fc01..94a7605051 100644 --- a/client/app/bundles/course/gradebook/components/GradebookWeightedTable.tsx +++ b/client/app/bundles/course/gradebook/components/GradebookWeightedTable.tsx @@ -44,6 +44,7 @@ import type { AssessmentContribution, WeightedRow } from '../computeWeighted'; import { computeStudentBreakdown, computeWeightedRows, + effectiveGrade, resolveTabWeights, usingDefaultWeights, } from '../computeWeighted'; @@ -164,6 +165,18 @@ const translations = defineMessages({ id: 'course.gradebook.GradebookWeightedTable.total', defaultMessage: 'Total', }, + gradeCapped: { + id: 'course.gradebook.GradebookWeightedTable.gradeCapped', + defaultMessage: 'Capped at {value}', + }, + gradeFloored: { + id: 'course.gradebook.GradebookWeightedTable.gradeFloored', + defaultMessage: 'Floored to {value}', + }, + gradeCountsAs: { + id: 'course.gradebook.GradebookWeightedTable.gradeCountsAs', + defaultMessage: 'Counts as {value}', + }, }); type DisplayMode = 'points' | 'percent'; @@ -302,6 +315,11 @@ const GradebookWeightedTable = ({ return next; }); + const assessmentsById = useMemo( + () => new Map(assessments.map((a) => [a.id, a])), + [assessments], + ); + const breakdownsByStudent = useMemo( () => new Map( @@ -346,24 +364,6 @@ const GradebookWeightedTable = ({ [categories, categoryTabCounts], ); - // A tab whose RIGHT edge is a tab-group (category) boundary — i.e. the last - // tab of its category. These boundaries (plus the identity|grades one) get a - // 2px separator instead of the 1px sub-column hairline: a 2px line always - // covers at least one full device pixel, so unlike a 1px line it can't wash - // out at an unlucky fractional column offset on a non-integer DPR. The data - // attribute is targeted by the table's `& [data-group-end]` sx rule. - const tabIsCategoryEnd = useMemo( - () => - resolvedTabs.map( - (tab, i) => - i === resolvedTabs.length - 1 || - tab.categoryId !== resolvedTabs[i + 1].categoryId, - ), - [resolvedTabs], - ); - const groupEndIf = (cond: boolean): { 'data-group-end'?: true } => - cond ? { 'data-group-end': true } : {}; - useLayoutEffect(() => { const row1 = row1Ref.current; const row2 = row2Ref.current; @@ -374,15 +374,9 @@ const GradebookWeightedTable = ({ // reflow the header after mount; with a one-shot measurement rows 2–3 keep // a stale `top` and stay permanently dislodged from the rows above them. const measure = (): void => { - // getBoundingClientRect (subpixel) not offsetHeight (integer-rounded): - // rows are fractional (32px min + lineHeight content), and a rounded - // `top` lands the stuck rows 2-3 a fraction off — opening thin gaps - // between header rows (body bleeds through) and overshooting the single - // rowSpan=3 frozen-left cell so the right header reads a touch taller. - const h1 = row1.getBoundingClientRect().height; - const h2 = row2.getBoundingClientRect().height; + const h1 = row1.offsetHeight; setRow2Top(h1); - setRow3Top(h1 + h2); + setRow3Top(h1 + row2.offsetHeight); }; measure(); @@ -533,11 +527,6 @@ const GradebookWeightedTable = ({ const visibility = toolbar.getColumnVisibility?.() ?? {}; const showEmail = (visibility.email ?? false) === true; const showExternalId = (visibility.externalId ?? false) === true; - // The rightmost visible identity column — its right edge is the - // identity|grades group boundary (drawn 2px, like the category boundaries). - let lastIdentityField: 'name' | 'email' | 'externalId' = 'name'; - if (showExternalId) lastIdentityField = 'externalId'; - else if (showEmail) lastIdentityField = 'email'; const allRowsSelected = body.allFilteredSelected ?? false; const someRowsSelected = body.someFilteredSelected ?? false; @@ -655,76 +644,44 @@ const GradebookWeightedTable = ({ toolbar — plus the pagination below, so the table fills the remaining viewport; shorter classes shrink to fit (no whitespace). */} ({ - maxHeight: 'calc(100vh - 22rem)', - overflowX: 'auto', - // Outer top + left edges of the grid. A static border on the - // scroll container sits at the viewport edge and is always - // visible — unlike a border on the , which scrolls out - // from under the sticky header / frozen column. The cells' own - // right + bottom box-shadow insets supply the other two outer - // edges (last column / last row). Solid grey (matching the cell - // lines), not the translucent `divider` which anti-aliases away - // at fractional positions on a non-integer DPR. - borderTop: `1px solid ${theme.palette.grey[400]}`, - borderLeft: `1px solid ${theme.palette.grey[400]}`, - })} + sx={{ maxHeight: 'calc(100vh - 22rem)', overflowX: 'auto' }} >
{ - // Interior grid lines: each cell draws its OWN right + bottom - // edge as a box-shadow inset, NOT a `border`. Rationale (this is - // a sticky-header table with frozen columns on a fractional DPR): - // (1) A box-shadow is painted inside the cell's own layer, so - // the lines travel with the sticky header rows and frozen - // checkbox/Name columns on scroll. (`border-collapse: - // collapse` paints one grid by the
at the cells' - // original positions and leaves it behind when they stick.) - // (2) At a fractional column boundary — a nowrap breakdown - // title widens the Name column to a fractional max-content - // width, cascading fractional offsets across every column — - // a 1px `border` lands between device pixels on a - // non-integer DPR (e.g. 1.33×) and the browser snaps it to - // ZERO width, vanishing (and different browsers drop - // different ones); a box-shadow renders a hairline instead. - // (3) box-shadow sidesteps MUI's `:last-child { border: 0 }`, - // so the last header/body row keeps its lines unaided. - // The outer top/left edges live on the TableContainer (a static - // frame at the scroll-viewport edge); the cells' right/bottom - // insets supply the outer right/bottom (last column / last row). - // - // The line is a SOLID grey, not the translucent `divider` - // (rgba(0,0,0,0.12)). At a fractional column boundary on a - // non-integer DPR the 1px line is anti-aliased across two device - // pixels at partial coverage; 50%-covered `divider` (→ 0.06) - // disappears, but a 50%-covered solid grey is still visibly grey. - // This is what keeps the tab-group separators (Missions | - // Tutorials | …) reliably visible at every layout. - const line = theme.palette.grey[400]; - const right = `inset -1px 0 0 ${line}`; - const bottom = `inset 0 -1px 0 ${line}`; - // 2px separator for tab-group boundaries (see `tabIsCategoryEnd`). - const groupRight = `inset -2px 0 0 ${line}`; + // One definition for every grid line so horizontal and vertical + // separators share the same width and colour. + const gridLine = `1px solid ${theme.palette.divider}`; return { tableLayout: 'auto', borderCollapse: 'separate', borderSpacing: 0, + // Outer top + left edges. Interior lines come from each cell's + // own bottom + right, so every separator stays a single 1px + // (no doubling) and the whole table reads as one uniform grid. + borderTop: gridLine, + borderLeft: gridLine, '& th, & td': { boxSizing: 'border-box', border: 0, - boxShadow: `${right}, ${bottom}`, + borderBottom: gridLine, + borderRight: gridLine, py: 0.25, px: 1, lineHeight: 1.2, height: 32, }, - // Tab-group separators: a 2px right edge that can't wash out - // at a fractional column offset. (0,2,0) outranks the - // `& th, & td` rule (0,1,1), so it wins on the marked cells. - '& [data-group-end]': { - boxShadow: `${groupRight}, ${bottom}`, + // MUI's default `.MuiTableRow-root:last-child th { border: 0 }` + // (specificity 0,2,1, the `:last-child` pseudo-class) outranks + // the `& th` rule above (0,1,1) and silently zeroes ALL borders + // on the weight row — it is the last in . Re-assert + // the grid lines with a higher-specificity selector so they + // survive through the "% of grade" row. + '& thead tr:last-of-type th': { + borderTop: gridLine, + borderBottom: gridLine, + borderRight: gridLine, }, }; }} @@ -755,7 +712,6 @@ const GradebookWeightedTable = ({ {t(tableTranslations.email)} @@ -780,7 +735,6 @@ const GradebookWeightedTable = ({ {showExternalId && ( {t(tableTranslations.externalId)} @@ -791,7 +745,6 @@ const GradebookWeightedTable = ({ key={cat.id} align="center" colSpan={categoryTabCounts.get(cat.id) ?? 1} - data-group-end sx={{ fontWeight: 600 }} > {cat.title} @@ -803,6 +756,10 @@ const GradebookWeightedTable = ({ sx={{ fontWeight: 600, minWidth: 120, + // Its bottom edge is the row2/row3 boundary, now drawn by + // the row-3 subheader's `borderTop`; drop this one so the + // Total column's separator stays a single 1px. + '&&': { borderBottom: 'none' }, }} > - {resolvedTabs.map((tab, i) => ( + {resolvedTabs.map((tab) => ( - {resolvedTabs.map((tab, i) => ( + {resolvedTabs.map((tab) => ( {tabSubheaderLabel(tab)} @@ -935,7 +894,6 @@ const GradebookWeightedTable = ({ /> {showEmail && ( - - {row.original.email} - + {row.original.email} )} {showExternalId && ( - - {row.original.externalId ?? ''} - + {row.original.externalId ?? ''} )} {row.original.subtotals.map((subtotal, i) => { const weight = resolvedTabs[i].gradebookWeight ?? 0; return ( - + {fmtDisplay( tabDisplayValue(subtotal, weight), columnPrecisions.tabs[i], @@ -1019,6 +965,24 @@ const GradebookWeightedTable = ({ a.grade === null ? `—/${a.maxGrade}` : `${a.grade}/${a.maxGrade}`; + const assessmentData = assessmentsById.get( + a.assessmentId, + ); + const eff = + a.grade != null && assessmentData != null + ? effectiveGrade(a.grade, assessmentData) + : null; + const wasCapped = + eff != null && + a.grade != null && + a.grade > a.maxGrade && + eff !== a.grade; + const wasFloored = + eff != null && + a.grade != null && + a.grade < 0 && + eff !== a.grade; + const clamped = wasCapped || wasFloored; return ( {`${gradeText} · ${isExcluded ? t(translations.excluded) : weightText}`} + {clamped && ( + + + + )} {/* One empty cell per visible identity column so the grid lines stay aligned with the rows above. These scroll with the table (only checkbox + Name are frozen), matching the student rows. */} - {showEmail && ( - - )} - {showExternalId && ( - - )} + {showEmail && } + {showExternalId && } {resolvedTabs.map((tab, i) => { const tabCellValue = isExcluded ? '—' @@ -1126,11 +1097,7 @@ const GradebookWeightedTable = ({ columnPrecisions.tabs[i], ); return ( - + {i === tabIdx ? tabCellValue : ''} ); diff --git a/client/app/bundles/course/gradebook/components/OutOfRangeAlert.tsx b/client/app/bundles/course/gradebook/components/OutOfRangeAlert.tsx new file mode 100644 index 0000000000..1b598f6727 --- /dev/null +++ b/client/app/bundles/course/gradebook/components/OutOfRangeAlert.tsx @@ -0,0 +1,63 @@ +import { FC } from 'react'; +import { defineMessages } from 'react-intl'; +import { Alert } from '@mui/material'; + +import useTranslation from 'lib/hooks/useTranslation'; + +const translations = defineMessages({ + warning: { + id: 'course.gradebook.OutOfRangeAlert.warning', + defaultMessage: + '{gradeCount, plural, one {# grade} other {# grades}} in the external {assessmentCount, plural, one {assessment} other {assessments}} {assessmentNames} {gradeCount, plural, one {is} other {are}} outside their range. Review before exporting.', + }, + warningWeighted: { + id: 'course.gradebook.OutOfRangeAlert.warningWeighted', + defaultMessage: + '{gradeCount, plural, one {# grade} other {# grades}} in the external {assessmentCount, plural, one {assessment} other {assessments}} {assessmentNames} {gradeCount, plural, one {is} other {are}} outside their range and {gradeCount, plural, one {is} other {are}} being capped or floored in the weighted total. Review before exporting.', + }, +}); + +interface Props { + gradeCount: number; + assessmentNames: string[]; + weightedViewEnabled: boolean; +} + +const formatAssessmentNames = (names: string[]): string => { + if (names.length === 0) return ''; + if (names.length === 1) return names[0]; + if (names.length === 2) return `${names[0]} and ${names[1]}`; + + return `${names.slice(0, -1).join(', ')} and ${names[names.length - 1]}`; +}; + +const OutOfRangeAlert: FC = ({ + gradeCount, + assessmentNames, + weightedViewEnabled, +}) => { + const { t } = useTranslation(); + if (gradeCount === 0) return null; + if (weightedViewEnabled) { + return ( + + {t(translations.warningWeighted, { + gradeCount, + assessmentCount: assessmentNames.length, + assessmentNames: formatAssessmentNames(assessmentNames), + })} + + ); + } + return ( + + {t(translations.warning, { + gradeCount, + assessmentCount: assessmentNames.length, + assessmentNames: formatAssessmentNames(assessmentNames), + })} + + ); +}; + +export default OutOfRangeAlert; diff --git a/client/app/bundles/course/gradebook/outOfRange.ts b/client/app/bundles/course/gradebook/outOfRange.ts new file mode 100644 index 0000000000..8068fd02ef --- /dev/null +++ b/client/app/bundles/course/gradebook/outOfRange.ts @@ -0,0 +1,36 @@ +import type { AssessmentData, SubmissionData } from 'types/course/gradebook'; + +export interface OutOfRangeSummary { + gradeCount: number; + assessmentNames: string[]; +} + +// Counts external grades that an ACTIVE bound is silently adjusting in the +// weighted total: above max only when capped, below 0 only when floored. A +// disabled toggle clamps nothing, so those grades are not flagged. Read-only — +// never mutates anything (mirrors effectiveGrade's read-time contract). +export const outOfRangeSummary = ( + assessments: AssessmentData[], + submissions: Pick[], +): OutOfRangeSummary => { + const byId = new Map(); + assessments.forEach((a) => { + if (a.external) byId.set(a.id, a); + }); + + let gradeCount = 0; + const offending = new Map(); + submissions.forEach((s) => { + if (s.grade == null) return; + const a = byId.get(s.assessmentId); + if (!a) return; + const over = (a.capAtMaximum ?? false) && s.grade > a.maxGrade; + const under = (a.floorAtZero ?? false) && s.grade < 0; + if (over || under) { + gradeCount += 1; + offending.set(a.id, a.title); + } + }); + + return { gradeCount, assessmentNames: Array.from(offending.values()) }; +}; diff --git a/client/app/bundles/course/gradebook/pages/GradebookIndex/index.tsx b/client/app/bundles/course/gradebook/pages/GradebookIndex/index.tsx index 69f218f48b..f3f9d08fab 100644 --- a/client/app/bundles/course/gradebook/pages/GradebookIndex/index.tsx +++ b/client/app/bundles/course/gradebook/pages/GradebookIndex/index.tsx @@ -1,4 +1,4 @@ -import { FC, useEffect, useState, useTransition } from 'react'; +import { FC, useEffect, useMemo, useState, useTransition } from 'react'; import { defineMessages } from 'react-intl'; import { useParams, useSearchParams } from 'react-router-dom'; import { PeopleAlt } from '@mui/icons-material'; @@ -15,8 +15,10 @@ import GradebookTable from '../../components/GradebookTable'; import GradebookWeightedTable from '../../components/GradebookWeightedTable'; import GradeLinkHint from '../../components/GradeLinkHint'; import ManageExternalAssessmentsButton from '../../components/manage/ManageExternalAssessmentsButton'; +import OutOfRangeAlert from '../../components/OutOfRangeAlert'; import WeightedViewHint from '../../components/WeightedViewHint'; import fetchGradebook from '../../operations'; +import { outOfRangeSummary } from '../../outOfRange'; import { getAssessments, getCanManageWeights, @@ -77,6 +79,11 @@ const GradebookIndex: FC = () => { const weightedViewEnabled = useAppSelector(getWeightedViewEnabled); const canManageWeights = useAppSelector(getCanManageWeights); + const rangeSummary = useMemo( + () => outOfRangeSummary(assessments, submissions), + [assessments, submissions], + ); + useEffect(() => { dispatch(fetchGradebook()) .finally(() => setIsLoading(false)) @@ -130,6 +137,7 @@ const GradebookIndex: FC = () => { toolbarAction={ canManageWeights ? : undefined } + weightedViewEnabled={weightedViewEnabled} /> ); } @@ -158,6 +166,13 @@ const GradebookIndex: FC = () => { {!isLoading && students.length > 0 && !(weightedViewEnabled && viewMode === 'weighted') && } + {!isLoading && students.length > 0 && ( + + )}
{isPending && (
diff --git a/client/locales/en.json b/client/locales/en.json index df2171c0a0..68eef63b34 100644 --- a/client/locales/en.json +++ b/client/locales/en.json @@ -9435,7 +9435,7 @@ "defaultMessage": "{title} grade for {name}" }, "course.gradebook.GradebookTable.gradeSaveError": { - "defaultMessage": "Could not save the grade. Please try again." + "defaultMessage": "Could not save the {title} grade for {name}. Please try again." }, "course.gradebook.GradebookTable.gradeSaved": { "defaultMessage": "Grade saved. {title} · {name}: {oldGrade} → {newGrade}" @@ -9571,5 +9571,38 @@ }, "course.gradebook.ProjectedTotalHint.policy": { "defaultMessage": "Totals count ungraded assessments as 0." + }, + "course.gradebook.GradebookTable.acceptEdit": { + "defaultMessage": "Accept" + }, + "course.gradebook.GradebookTable.revertEdit": { + "defaultMessage": "Revert" + }, + "course.gradebook.GradebookTable.gradeExceedsMax": { + "defaultMessage": "This grade exceeds the maximum of {max}." + }, + "course.gradebook.GradebookTable.gradeExceedsMaxWeighted": { + "defaultMessage": "This grade exceeds the maximum of {max}; its contribution to the weighted total is capped at {max}." + }, + "course.gradebook.GradebookTable.gradeBelowZero": { + "defaultMessage": "This grade is below 0." + }, + "course.gradebook.GradebookTable.gradeBelowZeroWeighted": { + "defaultMessage": "This grade is below 0; it is floored to 0 in the weighted total." + }, + "course.gradebook.GradebookWeightedTable.gradeCapped": { + "defaultMessage": "Capped at {value}" + }, + "course.gradebook.GradebookWeightedTable.gradeCountsAs": { + "defaultMessage": "Counts as {value}" + }, + "course.gradebook.GradebookWeightedTable.gradeFloored": { + "defaultMessage": "Floored to {value}" + }, + "course.gradebook.OutOfRangeAlert.warning": { + "defaultMessage": "{gradeCount, plural, one {# grade} other {# grades}} in the external {assessmentCount, plural, one {assessment} other {assessments}} {assessmentNames} {gradeCount, plural, one {is} other {are}} outside their range. Review before exporting." + }, + "course.gradebook.OutOfRangeAlert.warningWeighted": { + "defaultMessage": "{gradeCount, plural, one {# grade} other {# grades}} in the external {assessmentCount, plural, one {assessment} other {assessments}} {assessmentNames} {gradeCount, plural, one {is} other {are}} outside their range and {gradeCount, plural, one {is} other {are}} being capped or floored in the weighted total. Review before exporting." } } \ No newline at end of file diff --git a/client/locales/ko.json b/client/locales/ko.json index 5af41cdfbb..7f76deac15 100644 --- a/client/locales/ko.json +++ b/client/locales/ko.json @@ -9420,7 +9420,7 @@ "defaultMessage": "{name}의 {title} 성적" }, "course.gradebook.GradebookTable.gradeSaveError": { - "defaultMessage": "성적을 저장할 수 없습니다. 다시 시도해 주세요." + "defaultMessage": "{name}의 {title} 성적을 저장할 수 없습니다. 다시 시도해 주세요." }, "course.gradebook.GradebookTable.gradeSaved": { "defaultMessage": "성적이 저장되었습니다. {title} · {name}: {oldGrade} → {newGrade}" @@ -9562,5 +9562,38 @@ }, "course.gradebook.ManageExternalPanel.weight": { "defaultMessage": "가중치" + }, + "course.gradebook.GradebookTable.acceptEdit": { + "defaultMessage": "적용" + }, + "course.gradebook.GradebookTable.revertEdit": { + "defaultMessage": "되돌리기" + }, + "course.gradebook.GradebookTable.gradeExceedsMax": { + "defaultMessage": "이 성적은 최대 점수 {max}을(를) 초과합니다." + }, + "course.gradebook.GradebookTable.gradeExceedsMaxWeighted": { + "defaultMessage": "이 성적은 최대 점수 {max}을(를) 초과합니다. 가중 총점에 대한 기여도는 {max}(으)로 제한됩니다." + }, + "course.gradebook.GradebookTable.gradeBelowZero": { + "defaultMessage": "이 성적은 0보다 작습니다." + }, + "course.gradebook.GradebookTable.gradeBelowZeroWeighted": { + "defaultMessage": "이 성적은 0보다 작습니다. 가중 총점에서는 0으로 처리됩니다." + }, + "course.gradebook.GradebookWeightedTable.gradeCapped": { + "defaultMessage": "{value}(으)로 상한 처리" + }, + "course.gradebook.GradebookWeightedTable.gradeCountsAs": { + "defaultMessage": "{value}(으)로 계산" + }, + "course.gradebook.GradebookWeightedTable.gradeFloored": { + "defaultMessage": "{value}(으)로 하한 처리" + }, + "course.gradebook.OutOfRangeAlert.warning": { + "defaultMessage": "외부 평가 {assessmentNames}에서 {gradeCount, plural, other {성적 #개}}이(가) 범위를 벗어났습니다. 내보내기 전에 검토하세요." + }, + "course.gradebook.OutOfRangeAlert.warningWeighted": { + "defaultMessage": "외부 평가 {assessmentNames}에서 {gradeCount, plural, other {성적 #개}}이(가) 범위를 벗어나 가중 총점에서 제한되거나 하한 처리됩니다. 내보내기 전에 검토하세요." } } diff --git a/client/locales/zh.json b/client/locales/zh.json index d1c2dab45a..a1f98b9b5a 100644 --- a/client/locales/zh.json +++ b/client/locales/zh.json @@ -9414,7 +9414,7 @@ "defaultMessage": "{name} 的 {title} 成绩" }, "course.gradebook.GradebookTable.gradeSaveError": { - "defaultMessage": "无法保存成绩。请重试。" + "defaultMessage": "无法保存 {name} 的 {title} 成绩。请重试。" }, "course.gradebook.GradebookTable.gradeSaved": { "defaultMessage": "成绩已保存。{title} · {name}:{oldGrade} → {newGrade}" @@ -9556,5 +9556,38 @@ }, "course.gradebook.ManageExternalPanel.weight": { "defaultMessage": "权重" + }, + "course.gradebook.GradebookTable.acceptEdit": { + "defaultMessage": "接受" + }, + "course.gradebook.GradebookTable.revertEdit": { + "defaultMessage": "还原" + }, + "course.gradebook.GradebookTable.gradeExceedsMax": { + "defaultMessage": "此成绩超过了最高分 {max}。" + }, + "course.gradebook.GradebookTable.gradeExceedsMaxWeighted": { + "defaultMessage": "此成绩超过了最高分 {max};其对加权总分的贡献被限制为 {max}。" + }, + "course.gradebook.GradebookTable.gradeBelowZero": { + "defaultMessage": "此成绩低于 0。" + }, + "course.gradebook.GradebookTable.gradeBelowZeroWeighted": { + "defaultMessage": "此成绩低于 0;在加权总分中按 0 计算。" + }, + "course.gradebook.GradebookWeightedTable.gradeCapped": { + "defaultMessage": "上限为 {value}" + }, + "course.gradebook.GradebookWeightedTable.gradeCountsAs": { + "defaultMessage": "计为 {value}" + }, + "course.gradebook.GradebookWeightedTable.gradeFloored": { + "defaultMessage": "下限为 {value}" + }, + "course.gradebook.OutOfRangeAlert.warning": { + "defaultMessage": "外部评估 {assessmentNames} 中有 {gradeCount, plural, other {# 个成绩}}超出范围。导出前请检查。" + }, + "course.gradebook.OutOfRangeAlert.warningWeighted": { + "defaultMessage": "外部评估 {assessmentNames} 中有 {gradeCount, plural, other {# 个成绩}}超出范围,将在加权总分中被限制或下调。导出前请检查。" } }