Skip to content

Commit dee2b98

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. m
1 parent 232064d commit dee2b98

13 files changed

Lines changed: 2213 additions & 194 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: [],
@@ -99,6 +109,16 @@ const populatedState = {
99109
},
100110
};
101111

112+
const studentsNoAssessmentsState = {
113+
gradebook: {
114+
...populatedState.gradebook,
115+
categories: [],
116+
tabs: [],
117+
assessments: [],
118+
submissions: [],
119+
},
120+
};
121+
102122
const populatedStateWithGamification = {
103123
gradebook: {
104124
...populatedState.gradebook,
@@ -139,6 +159,55 @@ const populatedStateManagerWeightedOn = {
139159
},
140160
};
141161

162+
const populatedStateExternalInRange = {
163+
gradebook: {
164+
...populatedState.gradebook,
165+
assessments: [
166+
{
167+
id: 200,
168+
title: 'External Midterm',
169+
tabId: 10,
170+
maxGrade: 100,
171+
external: true,
172+
capAtMaximum: true,
173+
floorAtZero: true,
174+
},
175+
],
176+
submissions: [
177+
{ studentId: 1, assessmentId: 200, submissionId: 200, grade: 90 }, // within [0,100]
178+
],
179+
},
180+
};
181+
182+
const populatedStateWithOutOfRangeGrade = {
183+
gradebook: {
184+
...populatedState.gradebook,
185+
assessments: [
186+
{ id: 100, title: 'Quiz 1', tabId: 10, maxGrade: 10 },
187+
{
188+
id: 200,
189+
title: 'External Midterm',
190+
tabId: 10,
191+
maxGrade: 100,
192+
external: true,
193+
capAtMaximum: true,
194+
floorAtZero: true,
195+
},
196+
],
197+
submissions: [
198+
{ studentId: 1, assessmentId: 100, submissionId: 1000, grade: 8 },
199+
{ studentId: 1, assessmentId: 200, submissionId: 200, grade: 110 }, // above max, capped
200+
],
201+
},
202+
};
203+
204+
const populatedStateWithOutOfRangeGradeWeighted = {
205+
gradebook: {
206+
...populatedStateWithOutOfRangeGrade.gradebook,
207+
weightedViewEnabled: true,
208+
},
209+
};
210+
142211
beforeEach(() => {
143212
jest.clearAllMocks();
144213
mockFetchGradebook.mockReturnValue((): Promise<void> => Promise.resolve());
@@ -162,18 +231,44 @@ describe('GradebookIndex', () => {
162231
expect(await screen.findByText('Gradebook')).toBeInTheDocument();
163232
});
164233

165-
it('shows empty students message when there are no students', async () => {
166-
render(<GradebookIndex />, { state: noStudentsState });
234+
it('shows the grade-link hint in the all-assessments view', async () => {
235+
render(<GradebookIndex />, { state: populatedState });
167236
expect(
168-
await screen.findByText('No students enrolled yet'),
237+
await screen.findByText(
238+
/Click any grade to open that submission and adjust the marks/i,
239+
),
169240
).toBeInTheDocument();
170241
});
171242

172-
it('shows empty students message when both assessments and students are absent', async () => {
243+
it('hides the grade-link hint in the weighted-total view', async () => {
244+
render(<GradebookIndex />, { state: populatedStateWithWeightedView });
245+
const byWeightButton = await screen.findByText(/weighted total/i);
246+
fireEvent.click(byWeightButton);
247+
await screen.findByTestId(WEIGHTED_TABLE_TESTID);
248+
expect(
249+
screen.queryByText(
250+
/Click any grade to open that submission and adjust the marks/i,
251+
),
252+
).not.toBeInTheDocument();
253+
});
254+
255+
it('shows empty students message and renders no gradebook table when there are no students', async () => {
173256
render(<GradebookIndex />, { state: emptyState });
174257
expect(
175258
await screen.findByText('No students enrolled yet'),
176259
).toBeInTheDocument();
260+
expect(screen.queryByTestId(WEIGHTED_TABLE_TESTID)).not.toBeInTheDocument();
261+
});
262+
263+
it('renders the gradebook table when there are students but no assessments', async () => {
264+
render(<GradebookIndex />, { state: studentsNoAssessmentsState });
265+
expect(
266+
await screen.findByRole('button', { name: /export/i }),
267+
).toBeInTheDocument();
268+
expect(screen.getByText('Alice')).toBeInTheDocument();
269+
expect(
270+
screen.queryByText('No students enrolled yet'),
271+
).not.toBeInTheDocument();
177272
});
178273

179274
it('shows error toast when fetch fails', async () => {
@@ -196,7 +291,7 @@ describe('GradebookIndex', () => {
196291
).toBeInTheDocument();
197292
});
198293

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

227-
it('switches to Weighted Total view on toggle click', async () => {
228-
render(<GradebookIndex />, { state: populatedStateWithWeightedView });
322+
it('switches to Weighted total view on toggle click and reflects it in the URL', async () => {
323+
render(
324+
<>
325+
<GradebookIndex />
326+
<LocationSearch />
327+
</>,
328+
{ state: populatedStateWithWeightedView },
329+
);
229330
const byWeightButton = await screen.findByText(/weighted total/i);
230331
fireEvent.click(byWeightButton);
231332
expect(
232-
await screen.findByTestId('gradebook-weighted-table'),
333+
await screen.findByTestId(WEIGHTED_TABLE_TESTID),
334+
).toBeInTheDocument();
335+
expect(screen.getByTestId('location-search')).toHaveTextContent(
336+
'view=weighted',
337+
);
338+
339+
fireEvent.click(await screen.findByText(/all assessments/i));
340+
await waitFor(() =>
341+
expect(screen.getByTestId('location-search')).not.toHaveTextContent(
342+
'view=weighted',
343+
),
344+
);
345+
});
346+
347+
it('starts in Weighted total view when the URL requests it', async () => {
348+
render(<GradebookIndex />, {
349+
state: populatedStateWithWeightedView,
350+
at: ['/?view=weighted'],
351+
});
352+
expect(
353+
await screen.findByTestId(WEIGHTED_TABLE_TESTID),
233354
).toBeInTheDocument();
234355
});
235356

@@ -239,7 +360,7 @@ describe('GradebookIndex', () => {
239360
});
240361
const byWeightButton = await screen.findByText(/weighted total/i);
241362
fireEvent.click(byWeightButton);
242-
await screen.findByTestId('gradebook-weighted-table');
363+
await screen.findByTestId(WEIGHTED_TABLE_TESTID);
243364
fireEvent.click(
244365
await screen.findByRole('button', { name: /select columns/i }),
245366
);
@@ -248,6 +369,75 @@ describe('GradebookIndex', () => {
248369
expect(within(dialog).queryByText('Total XP')).not.toBeInTheDocument();
249370
});
250371

372+
it('shows the manage button and not the old import/add buttons', async () => {
373+
render(<GradebookIndex />, { state: populatedStateManagerWeightedOff });
374+
expect(
375+
await screen.findByRole('button', {
376+
name: 'Manage external assessments',
377+
}),
378+
).toBeVisible();
379+
expect(
380+
screen.queryByRole('button', { name: 'Import external assessments' }),
381+
).not.toBeInTheDocument();
382+
expect(
383+
screen.queryByRole('button', { name: 'Add external assessment' }),
384+
).not.toBeInTheDocument();
385+
});
386+
387+
it('shows the manage button in the weighted-total view for managers', async () => {
388+
render(<GradebookIndex />, { state: populatedStateManagerWeightedOn });
389+
const byWeightButton = await screen.findByText(/weighted total/i);
390+
fireEvent.click(byWeightButton);
391+
await screen.findByTestId(WEIGHTED_TABLE_TESTID);
392+
expect(
393+
screen.getByRole('button', { name: 'Manage external assessments' }),
394+
).toBeVisible();
395+
});
396+
397+
it('does not show the manage button to staff who cannot manage weights', async () => {
398+
render(<GradebookIndex />, { state: populatedState });
399+
await screen.findByRole('button', { name: /export/i }); // wait for load
400+
expect(
401+
screen.queryByRole('button', { name: 'Manage external assessments' }),
402+
).not.toBeInTheDocument();
403+
});
404+
405+
describe('out-of-range banner', () => {
406+
it('shows the banner when there are out-of-range grades', async () => {
407+
render(<GradebookIndex />, { state: populatedStateWithOutOfRangeGrade });
408+
expect(
409+
await screen.findByText(/outside their range/i),
410+
).toBeInTheDocument();
411+
});
412+
413+
it('shows the weighted-total wording when the weighted view is enabled', async () => {
414+
render(<GradebookIndex />, {
415+
state: populatedStateWithOutOfRangeGradeWeighted,
416+
});
417+
expect(
418+
await screen.findByText(
419+
/being capped or floored in the weighted total/i,
420+
),
421+
).toBeInTheDocument();
422+
});
423+
424+
it('does not show the banner when all grades are in range', async () => {
425+
render(<GradebookIndex />, { state: populatedStateExternalInRange });
426+
await screen.findByRole('button', { name: /export/i }); // wait for load
427+
expect(
428+
screen.queryByText(/outside their range/i),
429+
).not.toBeInTheDocument();
430+
});
431+
432+
it('does not show the banner when there are no students', async () => {
433+
render(<GradebookIndex />, { state: noStudentsState });
434+
await screen.findByText('No students enrolled yet');
435+
expect(
436+
screen.queryByText(/outside their range/i),
437+
).not.toBeInTheDocument();
438+
});
439+
});
440+
251441
describe('weighted-view discoverability hint', () => {
252442
it('shows the hint to managers when the weighted view is off', async () => {
253443
render(<GradebookIndex />, { state: populatedStateManagerWeightedOff });

0 commit comments

Comments
 (0)