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 {# 个成绩}}超出范围,将在加权总分中被限制或下调。导出前请检查。"
}
}