Skip to content

Commit dba6faa

Browse files
committed
feat(gradebook): add weighted view UI — table, config dialog, column picker
- add GradebookWeightedTable with 3-row sticky header showing per-tab additive weighted subtotals and per-student totals - add ConfigureWeightsPrompt for managers to edit tab weights (0–100) with real-time sum-to-100 warning and integer validation - add All vs By-weight view toggle in GradebookIndex (role-aware) - add column picker (Student info, Gamification groups); External ID defaults visible when any student has one, hidden otherwise - add computeWeighted helpers (computeTabSubtotal, computeStudentTotal, sumWeights) with full test coverage - add useDismissibleOnce hook for one-time dismissible hint UI
1 parent 8c06e66 commit dba6faa

31 files changed

Lines changed: 3121 additions & 429 deletions

app/controllers/components/course/gradebook_component.rb

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,12 @@ def self.display_name
77
end
88

99
def sidebar_items
10+
main_sidebar_items + settings_sidebar_items
11+
end
12+
13+
private
14+
15+
def main_sidebar_items
1016
return [] unless can?(:read_gradebook, current_course)
1117

1218
[
@@ -20,4 +26,17 @@ def sidebar_items
2026
}
2127
]
2228
end
29+
30+
def settings_sidebar_items
31+
return [] unless can?(:manage_gradebook_settings, current_course)
32+
33+
[
34+
{
35+
key: self.class.key,
36+
type: :settings,
37+
weight: 14,
38+
path: course_admin_gradebook_path(current_course)
39+
}
40+
]
41+
end
2342
end
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
// File used for jest moduleNameMapper - empty locale messages for tests
2+
module.exports = {};

client/app/__test__/setup.js

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,3 +65,15 @@ jest.mock('react-router-dom', () => ({
6565
useNavigate: jest.fn(),
6666
unstable_usePrompt: jest.fn(),
6767
}));
68+
69+
// Replace I18nProvider with a synchronous stub so tests using test-utils
70+
// don't stall on async locale loading.
71+
jest.mock('lib/components/wrappers/I18nProvider', () => {
72+
const { IntlProvider } = require('react-intl');
73+
const SyncI18nProvider = ({ children }) => (
74+
<IntlProvider defaultLocale="en" locale="en" messages={{}}>
75+
{children}
76+
</IntlProvider>
77+
);
78+
return { __esModule: true, default: SyncI18nProvider };
79+
});

client/app/api/course/Gradebook.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { GradebookData } from 'types/course/gradebook';
1+
import { GradebookData, UpdateWeightsPayload } from 'types/course/gradebook';
22

33
import { APIResponse } from 'api/types';
44

@@ -12,4 +12,10 @@ export default class GradebookAPI extends BaseCourseAPI {
1212
index(): APIResponse<GradebookData> {
1313
return this.client.get(this.#urlPrefix);
1414
}
15+
16+
updateWeights(
17+
payload: UpdateWeightsPayload,
18+
): APIResponse<UpdateWeightsPayload> {
19+
return this.client.patch(`${this.#urlPrefix}/weights`, payload);
20+
}
1521
}
Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
1+
import { fireEvent, render, screen, waitFor } from 'test-utils';
2+
3+
import ConfigureWeightsPrompt from '../components/ConfigureWeightsPrompt';
4+
import * as operations from '../operations';
5+
6+
jest
7+
.spyOn(operations, 'updateGradebookWeights')
8+
.mockReturnValue(async () => {});
9+
10+
const categories = [{ id: 1, title: 'Missions' }];
11+
const tabs = [
12+
{ id: 10, title: 'Assignments', categoryId: 1, gradebookWeight: 50 },
13+
{ id: 11, title: 'Optional', categoryId: 1, gradebookWeight: 50 },
14+
];
15+
const assessments = [
16+
{ id: 101, title: 'Assignment 1', tabId: 10, maxGrade: 100 },
17+
{ id: 102, title: 'Assignment 2', tabId: 10, maxGrade: 100 },
18+
];
19+
20+
const setup = (overrides = {}): ReturnType<typeof render> =>
21+
render(
22+
<ConfigureWeightsPrompt
23+
assessments={assessments}
24+
categories={categories}
25+
onClose={jest.fn()}
26+
open
27+
tabs={tabs}
28+
{...overrides}
29+
/>,
30+
);
31+
32+
describe('<ConfigureWeightsPrompt />', () => {
33+
beforeEach(() => {
34+
jest.clearAllMocks();
35+
});
36+
37+
it('renders one input per tab grouped by category', () => {
38+
setup();
39+
expect(screen.getByText('Missions')).toBeInTheDocument();
40+
expect(screen.getByLabelText('Assignments')).toHaveValue(50);
41+
expect(screen.getByLabelText('Optional')).toHaveValue(50);
42+
});
43+
44+
it('shows Total: 100% with no warning when sum = 100', () => {
45+
setup();
46+
expect(screen.getByText(/Total:\s*100%/)).toBeInTheDocument();
47+
expect(screen.queryByText(/do not sum to 100/i)).not.toBeInTheDocument();
48+
});
49+
50+
it('shows warning when sum != 100', () => {
51+
setup();
52+
fireEvent.change(screen.getByLabelText('Optional'), {
53+
target: { value: '30' },
54+
});
55+
expect(screen.getByText(/Total:\s*80%/)).toBeInTheDocument();
56+
expect(screen.getByText(/do not sum to 100/i)).toBeInTheDocument();
57+
});
58+
59+
it('shows inline error for >100', () => {
60+
setup();
61+
fireEvent.change(screen.getByLabelText('Assignments'), {
62+
target: { value: '101' },
63+
});
64+
expect(screen.getByText(/must be at most 100/i)).toBeInTheDocument();
65+
});
66+
67+
it('shows inline error for negative', () => {
68+
setup();
69+
fireEvent.change(screen.getByLabelText('Optional'), {
70+
target: { value: '-1' },
71+
});
72+
expect(screen.getByText(/must be at least 0/i)).toBeInTheDocument();
73+
});
74+
75+
it('Save dispatches updateGradebookWeights with { tabId, weight } only', async () => {
76+
setup();
77+
fireEvent.change(screen.getByLabelText('Optional'), {
78+
target: { value: '40' },
79+
});
80+
fireEvent.click(screen.getByRole('button', { name: /save/i }));
81+
await waitFor(() => {
82+
expect(operations.updateGradebookWeights).toHaveBeenCalledWith([
83+
{ tabId: 10, weight: 50 },
84+
{ tabId: 11, weight: 40 },
85+
]);
86+
});
87+
});
88+
89+
it('Cancel does not dispatch', () => {
90+
setup();
91+
fireEvent.change(screen.getByLabelText('Optional'), {
92+
target: { value: '40' },
93+
});
94+
fireEvent.click(screen.getByRole('button', { name: /cancel/i }));
95+
expect(operations.updateGradebookWeights).not.toHaveBeenCalled();
96+
});
97+
98+
it('assessment list is hidden by default and shown on expand', () => {
99+
setup();
100+
expect(screen.queryByText('Assignment 1')).not.toBeVisible();
101+
const expandBtns = screen.getAllByRole('button', { name: '' });
102+
fireEvent.click(expandBtns[0]);
103+
expect(screen.getByText('Assignment 1')).toBeVisible();
104+
expect(screen.getByText('Assignment 2')).toBeVisible();
105+
});
106+
107+
it('shows derived % of grade that updates live when weight changes', () => {
108+
setup();
109+
const expandBtns = screen.getAllByRole('button', { name: '' });
110+
fireEvent.click(expandBtns[0]);
111+
// weight=50, each assessment is 50% of tab → 25.0% of grade each
112+
expect(screen.getAllByText('25.0% of grade')).toHaveLength(2);
113+
fireEvent.change(screen.getByLabelText('Assignments'), {
114+
target: { value: '60' },
115+
});
116+
expect(screen.getAllByText('30.0% of grade')).toHaveLength(2);
117+
});
118+
119+
it('disables expand button for tabs with no assessments', () => {
120+
setup();
121+
const expandBtns = screen.getAllByRole('button', { name: '' });
122+
expect(expandBtns[1]).toBeDisabled();
123+
});
124+
125+
it('does not render an Exclude checkbox', () => {
126+
setup();
127+
expect(
128+
screen.queryByRole('checkbox', { name: /exclude/i }),
129+
).not.toBeInTheDocument();
130+
});
131+
});

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

Lines changed: 102 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { fireEvent, render, screen, waitFor } from 'test-utils';
1+
import { fireEvent, render, screen, waitFor, within } from 'test-utils';
22

33
import toast from 'lib/hooks/toast';
44

@@ -33,6 +33,8 @@ const emptyState = {
3333
submissions: [],
3434
gamificationEnabled: false,
3535
userId: 0,
36+
weightedViewEnabled: false,
37+
canManageWeights: false,
3638
},
3739
};
3840

@@ -45,6 +47,8 @@ const noStudentsState = {
4547
submissions: [],
4648
gamificationEnabled: false,
4749
userId: 0,
50+
weightedViewEnabled: false,
51+
canManageWeights: false,
4852
},
4953
};
5054

@@ -66,6 +70,8 @@ const populatedState = {
6670
submissions: [{ studentId: 1, assessmentId: 100, grade: 8 }],
6771
gamificationEnabled: false,
6872
userId: 0,
73+
weightedViewEnabled: false,
74+
canManageWeights: false,
6975
},
7076
};
7177

@@ -76,6 +82,39 @@ const populatedStateWithGamification = {
7682
},
7783
};
7884

85+
const populatedStateWithWeightedView = {
86+
gradebook: {
87+
...populatedState.gradebook,
88+
weightedViewEnabled: true,
89+
canManageWeights: false,
90+
},
91+
};
92+
93+
const populatedStateWithWeightedViewAndGamification = {
94+
gradebook: {
95+
...populatedState.gradebook,
96+
weightedViewEnabled: true,
97+
gamificationEnabled: true,
98+
canManageWeights: false,
99+
},
100+
};
101+
102+
const populatedStateManagerWeightedOff = {
103+
gradebook: {
104+
...populatedState.gradebook,
105+
weightedViewEnabled: false,
106+
canManageWeights: true,
107+
},
108+
};
109+
110+
const populatedStateManagerWeightedOn = {
111+
gradebook: {
112+
...populatedState.gradebook,
113+
weightedViewEnabled: true,
114+
canManageWeights: true,
115+
},
116+
};
117+
79118
beforeEach(() => {
80119
jest.clearAllMocks();
81120
mockFetchGradebook.mockReturnValue((): Promise<void> => Promise.resolve());
@@ -147,4 +186,66 @@ describe('GradebookIndex', () => {
147186
),
148187
).toBeInTheDocument();
149188
});
189+
190+
it('does not render view toggle when weightedViewEnabled is false', async () => {
191+
render(<GradebookIndex />, { state: populatedState });
192+
// Wait for loading to finish
193+
await screen.findByRole('button', { name: /export/i });
194+
expect(screen.queryByText(/by weight/i)).not.toBeInTheDocument();
195+
});
196+
197+
it('renders view toggle when weightedViewEnabled is true', async () => {
198+
render(<GradebookIndex />, { state: populatedStateWithWeightedView });
199+
expect(await screen.findByText(/all assessments/i)).toBeInTheDocument();
200+
expect(await screen.findByText(/by weight/i)).toBeInTheDocument();
201+
});
202+
203+
it('switches to By weight view on toggle click', async () => {
204+
render(<GradebookIndex />, { state: populatedStateWithWeightedView });
205+
const byWeightButton = await screen.findByText(/by weight/i);
206+
fireEvent.click(byWeightButton);
207+
expect(
208+
await screen.findByTestId('gradebook-weighted-table'),
209+
).toBeInTheDocument();
210+
});
211+
212+
it('weighted view exposes gamification columns in picker when gamification is enabled', async () => {
213+
render(<GradebookIndex />, {
214+
state: populatedStateWithWeightedViewAndGamification,
215+
});
216+
const byWeightButton = await screen.findByText(/by weight/i);
217+
fireEvent.click(byWeightButton);
218+
await screen.findByTestId('gradebook-weighted-table');
219+
fireEvent.click(
220+
await screen.findByRole('button', { name: /select columns/i }),
221+
);
222+
const dialog = await screen.findByRole('dialog');
223+
expect(within(dialog).getByText('Level')).toBeInTheDocument();
224+
expect(within(dialog).getByText('Total XP')).toBeInTheDocument();
225+
});
226+
227+
describe('weighted-view discoverability hint', () => {
228+
it('shows the hint to managers when the weighted view is off', async () => {
229+
render(<GradebookIndex />, { state: populatedStateManagerWeightedOff });
230+
expect(
231+
await screen.findByRole('link', { name: /gradebook settings/i }),
232+
).toBeInTheDocument();
233+
});
234+
235+
it('does not show the hint once the weighted view is enabled', async () => {
236+
render(<GradebookIndex />, { state: populatedStateManagerWeightedOn });
237+
await screen.findByText(/by weight/i); // wait for data to load
238+
expect(
239+
screen.queryByRole('link', { name: /gradebook settings/i }),
240+
).not.toBeInTheDocument();
241+
});
242+
243+
it('does not show the hint to staff who cannot manage weights', async () => {
244+
render(<GradebookIndex />, { state: populatedState });
245+
await screen.findByRole('button', { name: /export/i }); // wait for load
246+
expect(
247+
screen.queryByRole('link', { name: /gradebook settings/i }),
248+
).not.toBeInTheDocument();
249+
});
250+
});
150251
});

0 commit comments

Comments
 (0)