Skip to content

Commit c75dd03

Browse files
committed
feat(gradebook): cap-entry editing, over-max flags, and out-of-range warnings
External grade cells gain decimal-bounded inline editing with explicit accept/revert controls, a saving indicator, and a persistent save confirmation that echoes the student, assessment, and old → new grade so a row/column misclick is caught. Over-max and below-zero warning markers flag out-of-bound values. The weighted view annotates contributions that an active bound capped or floored. GradebookIndex surfaces an out-of-range summary alert above the table, and threads weightedViewEnabled into GradebookTable so the cell messaging matches the active view.
1 parent e26ee62 commit c75dd03

11 files changed

Lines changed: 1858 additions & 292 deletions

File tree

client/app/bundles/course/gradebook/__tests__/GradebookIndex.test.tsx

Lines changed: 199 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,18 @@
1+
import { useLocation } from 'react-router-dom';
12
import { fireEvent, render, screen, waitFor, within } from 'test-utils';
23

34
import toast from 'lib/hooks/toast';
45

56
import fetchGradebook from '../operations';
67
import GradebookIndex from '../pages/GradebookIndex';
78

9+
// TestApp mounts a MemoryRouter, whose location lives in memory and never
10+
// touches window.location. This spy surfaces the router's current search
11+
// string into the DOM so tests can assert on URL changes.
12+
const LocationSearch = (): JSX.Element => (
13+
<div data-testid="location-search">{useLocation().search}</div>
14+
);
15+
816
jest.mock('../../container/CourseLoader', () => ({
917
useCourseContext: (): { courseTitle: string; id: number } => ({
1018
courseTitle: 'Test Course',
@@ -24,6 +32,8 @@ jest.mock('../operations', () => ({
2432

2533
const mockFetchGradebook = fetchGradebook as jest.Mock;
2634

35+
const WEIGHTED_TABLE_TESTID = 'gradebook-weighted-table';
36+
2737
const emptyState = {
2838
gradebook: {
2939
categories: [],
@@ -74,6 +84,16 @@ const populatedState = {
7484
},
7585
};
7686

87+
const studentsNoAssessmentsState = {
88+
gradebook: {
89+
...populatedState.gradebook,
90+
categories: [],
91+
tabs: [],
92+
assessments: [],
93+
submissions: [],
94+
},
95+
};
96+
7797
const populatedStateWithGamification = {
7898
gradebook: {
7999
...populatedState.gradebook,
@@ -114,6 +134,55 @@ const populatedStateManagerWeightedOn = {
114134
},
115135
};
116136

137+
const populatedStateExternalInRange = {
138+
gradebook: {
139+
...populatedState.gradebook,
140+
assessments: [
141+
{
142+
id: 200,
143+
title: 'External Midterm',
144+
tabId: 10,
145+
maxGrade: 100,
146+
external: true,
147+
capAtMaximum: true,
148+
floorAtZero: true,
149+
},
150+
],
151+
submissions: [
152+
{ studentId: 1, assessmentId: 200, submissionId: 200, grade: 90 }, // within [0,100]
153+
],
154+
},
155+
};
156+
157+
const populatedStateWithOutOfRangeGrade = {
158+
gradebook: {
159+
...populatedState.gradebook,
160+
assessments: [
161+
{ id: 100, title: 'Quiz 1', tabId: 10, maxGrade: 10 },
162+
{
163+
id: 200,
164+
title: 'External Midterm',
165+
tabId: 10,
166+
maxGrade: 100,
167+
external: true,
168+
capAtMaximum: true,
169+
floorAtZero: true,
170+
},
171+
],
172+
submissions: [
173+
{ studentId: 1, assessmentId: 100, submissionId: 1000, grade: 8 },
174+
{ studentId: 1, assessmentId: 200, submissionId: 200, grade: 110 }, // above max, capped
175+
],
176+
},
177+
};
178+
179+
const populatedStateWithOutOfRangeGradeWeighted = {
180+
gradebook: {
181+
...populatedStateWithOutOfRangeGrade.gradebook,
182+
weightedViewEnabled: true,
183+
},
184+
};
185+
117186
beforeEach(() => {
118187
jest.clearAllMocks();
119188
mockFetchGradebook.mockReturnValue((): Promise<void> => Promise.resolve());
@@ -137,18 +206,44 @@ describe('GradebookIndex', () => {
137206
expect(await screen.findByText('Gradebook')).toBeInTheDocument();
138207
});
139208

140-
it('shows empty students message when there are no students', async () => {
141-
render(<GradebookIndex />, { state: noStudentsState });
209+
it('shows the grade-link hint in the all-assessments view', async () => {
210+
render(<GradebookIndex />, { state: populatedState });
142211
expect(
143-
await screen.findByText('No students enrolled yet'),
212+
await screen.findByText(
213+
/Click any grade to open that submission and adjust the marks/i,
214+
),
144215
).toBeInTheDocument();
145216
});
146217

147-
it('shows empty students message when both assessments and students are absent', async () => {
218+
it('hides the grade-link hint in the weighted-total view', async () => {
219+
render(<GradebookIndex />, { state: populatedStateWithWeightedView });
220+
const byWeightButton = await screen.findByText(/weighted total/i);
221+
fireEvent.click(byWeightButton);
222+
await screen.findByTestId(WEIGHTED_TABLE_TESTID);
223+
expect(
224+
screen.queryByText(
225+
/Click any grade to open that submission and adjust the marks/i,
226+
),
227+
).not.toBeInTheDocument();
228+
});
229+
230+
it('shows empty students message and renders no gradebook table when there are no students', async () => {
148231
render(<GradebookIndex />, { state: emptyState });
149232
expect(
150233
await screen.findByText('No students enrolled yet'),
151234
).toBeInTheDocument();
235+
expect(screen.queryByTestId(WEIGHTED_TABLE_TESTID)).not.toBeInTheDocument();
236+
});
237+
238+
it('renders the gradebook table when there are students but no assessments', async () => {
239+
render(<GradebookIndex />, { state: studentsNoAssessmentsState });
240+
expect(
241+
await screen.findByRole('button', { name: /export/i }),
242+
).toBeInTheDocument();
243+
expect(screen.getByText('Alice')).toBeInTheDocument();
244+
expect(
245+
screen.queryByText('No students enrolled yet'),
246+
).not.toBeInTheDocument();
152247
});
153248

154249
it('shows error toast when fetch fails', async () => {
@@ -171,7 +266,7 @@ describe('GradebookIndex', () => {
171266
).toBeInTheDocument();
172267
});
173268

174-
it('shows grade-and-gamification hint in column picker when gamification is enabled and no data cols selected', async () => {
269+
it('shows grade-and-gamification hint in column picker after enabling a gamification column with no grade columns selected', async () => {
175270
render(<GradebookIndex />, { state: populatedStateWithGamification });
176271
fireEvent.click(
177272
await screen.findByRole('button', { name: /select columns/i }),
@@ -199,12 +294,38 @@ describe('GradebookIndex', () => {
199294
expect(await screen.findByText(/weighted total/i)).toBeInTheDocument();
200295
});
201296

202-
it('switches to Weighted total view on toggle click', async () => {
203-
render(<GradebookIndex />, { state: populatedStateWithWeightedView });
297+
it('switches to Weighted total view on toggle click and reflects it in the URL', async () => {
298+
render(
299+
<>
300+
<GradebookIndex />
301+
<LocationSearch />
302+
</>,
303+
{ state: populatedStateWithWeightedView },
304+
);
204305
const byWeightButton = await screen.findByText(/weighted total/i);
205306
fireEvent.click(byWeightButton);
206307
expect(
207-
await screen.findByTestId('gradebook-weighted-table'),
308+
await screen.findByTestId(WEIGHTED_TABLE_TESTID),
309+
).toBeInTheDocument();
310+
expect(screen.getByTestId('location-search')).toHaveTextContent(
311+
'view=weighted',
312+
);
313+
314+
fireEvent.click(await screen.findByText(/all assessments/i));
315+
await waitFor(() =>
316+
expect(screen.getByTestId('location-search')).not.toHaveTextContent(
317+
'view=weighted',
318+
),
319+
);
320+
});
321+
322+
it('starts in Weighted total view when the URL requests it', async () => {
323+
render(<GradebookIndex />, {
324+
state: populatedStateWithWeightedView,
325+
at: ['/?view=weighted'],
326+
});
327+
expect(
328+
await screen.findByTestId(WEIGHTED_TABLE_TESTID),
208329
).toBeInTheDocument();
209330
});
210331

@@ -214,7 +335,7 @@ describe('GradebookIndex', () => {
214335
});
215336
const byWeightButton = await screen.findByText(/weighted total/i);
216337
fireEvent.click(byWeightButton);
217-
await screen.findByTestId('gradebook-weighted-table');
338+
await screen.findByTestId(WEIGHTED_TABLE_TESTID);
218339
fireEvent.click(
219340
await screen.findByRole('button', { name: /select columns/i }),
220341
);
@@ -223,6 +344,75 @@ describe('GradebookIndex', () => {
223344
expect(within(dialog).queryByText('Total XP')).not.toBeInTheDocument();
224345
});
225346

347+
it('shows the manage button and not the old import/add buttons', async () => {
348+
render(<GradebookIndex />, { state: populatedStateManagerWeightedOff });
349+
expect(
350+
await screen.findByRole('button', {
351+
name: 'Manage external assessments',
352+
}),
353+
).toBeVisible();
354+
expect(
355+
screen.queryByRole('button', { name: 'Import external assessments' }),
356+
).not.toBeInTheDocument();
357+
expect(
358+
screen.queryByRole('button', { name: 'Add external assessment' }),
359+
).not.toBeInTheDocument();
360+
});
361+
362+
it('shows the manage button in the weighted-total view for managers', async () => {
363+
render(<GradebookIndex />, { state: populatedStateManagerWeightedOn });
364+
const byWeightButton = await screen.findByText(/weighted total/i);
365+
fireEvent.click(byWeightButton);
366+
await screen.findByTestId(WEIGHTED_TABLE_TESTID);
367+
expect(
368+
screen.getByRole('button', { name: 'Manage external assessments' }),
369+
).toBeVisible();
370+
});
371+
372+
it('does not show the manage button to staff who cannot manage weights', async () => {
373+
render(<GradebookIndex />, { state: populatedState });
374+
await screen.findByRole('button', { name: /export/i }); // wait for load
375+
expect(
376+
screen.queryByRole('button', { name: 'Manage external assessments' }),
377+
).not.toBeInTheDocument();
378+
});
379+
380+
describe('out-of-range banner', () => {
381+
it('shows the banner when there are out-of-range grades', async () => {
382+
render(<GradebookIndex />, { state: populatedStateWithOutOfRangeGrade });
383+
expect(
384+
await screen.findByText(/outside their range/i),
385+
).toBeInTheDocument();
386+
});
387+
388+
it('shows the weighted-total wording when the weighted view is enabled', async () => {
389+
render(<GradebookIndex />, {
390+
state: populatedStateWithOutOfRangeGradeWeighted,
391+
});
392+
expect(
393+
await screen.findByText(
394+
/being capped or floored in the weighted total/i,
395+
),
396+
).toBeInTheDocument();
397+
});
398+
399+
it('does not show the banner when all grades are in range', async () => {
400+
render(<GradebookIndex />, { state: populatedStateExternalInRange });
401+
await screen.findByRole('button', { name: /export/i }); // wait for load
402+
expect(
403+
screen.queryByText(/outside their range/i),
404+
).not.toBeInTheDocument();
405+
});
406+
407+
it('does not show the banner when there are no students', async () => {
408+
render(<GradebookIndex />, { state: noStudentsState });
409+
await screen.findByText('No students enrolled yet');
410+
expect(
411+
screen.queryByText(/outside their range/i),
412+
).not.toBeInTheDocument();
413+
});
414+
});
415+
226416
describe('weighted-view discoverability hint', () => {
227417
it('shows the hint to managers when the weighted view is off', async () => {
228418
render(<GradebookIndex />, { state: populatedStateManagerWeightedOff });

0 commit comments

Comments
 (0)