Skip to content

Commit 7dcfa0f

Browse files
authored
Refactor grading dashboard and reduce API overhead (#2643)
* Use useSession hook instead of useTypedSelector * Show all submissions only for admins by default * Add FIXME * Refactor CSV exporter to separate file * Rename `data` to `submissions` Improves descriptiveness. * Remove unnecessary GradingDashboard abstraction * Simplify redundant code * Move ungraded submission filter to GradingUtils Improves readability. * Refactor filtering logic * Rename `showGraded` to `showAllSubmissions` for clarity * Pass a new copy of submissions array to table (immutability) * Move conditional logic into array filter * Create SimpleDropdown custom component * Allow staff to view all groups via dropdown
1 parent 39dafbf commit 7dcfa0f

File tree

4 files changed

+204
-144
lines changed

4 files changed

+204
-144
lines changed

src/commons/SimpleDropdown.tsx

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
import { Button, Menu, MenuItem } from '@blueprintjs/core';
2+
import { Popover2 } from '@blueprintjs/popover2';
3+
import { useState } from 'react';
4+
5+
type OptionType = { value: any; label: string };
6+
type Props<T extends OptionType> = {
7+
options: T[];
8+
defaultValue?: T['value'];
9+
onClick?: (v: T['value']) => void;
10+
buttonProps?: Partial<React.ComponentProps<typeof Button>>;
11+
popoverProps?: Partial<React.ComponentProps<typeof Popover2>>;
12+
};
13+
14+
function SimpleDropdown<T extends OptionType>(props: Props<T>) {
15+
const { options, defaultValue, onClick, buttonProps, popoverProps } = props;
16+
const [selectedOption, setSelectedOption] = useState(defaultValue);
17+
18+
const handleClick = (value: T['value']) => {
19+
setSelectedOption(value);
20+
onClick?.(value);
21+
};
22+
23+
const buttonLabel = () => {
24+
const option = options.find(({ value }) => value === selectedOption);
25+
return option?.label;
26+
};
27+
28+
return (
29+
<Popover2
30+
{...popoverProps}
31+
interactionKind="click"
32+
content={
33+
<Menu>
34+
{options.map(({ value, label }) => (
35+
<MenuItem text={label} onClick={() => handleClick(value)} />
36+
))}
37+
</Menu>
38+
}
39+
>
40+
<Button {...buttonProps}>{buttonLabel()}</Button>
41+
</Popover2>
42+
);
43+
}
44+
45+
export default SimpleDropdown;
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
import { GradingStatuses } from 'src/commons/assessment/AssessmentTypes';
2+
3+
import { GradingOverview } from './GradingTypes';
4+
5+
export const isSubmissionUngraded = (s: GradingOverview): boolean => {
6+
return s.submissionStatus !== 'submitted' || s.gradingStatus !== GradingStatuses.graded;
7+
};
8+
9+
export const exportGradingCSV = (gradingOverviews: GradingOverview[] | undefined) => {
10+
if (!gradingOverviews) return;
11+
12+
const win = document.defaultView || window;
13+
if (!win) {
14+
console.warn('There is no `window` associated with the current `document`');
15+
return;
16+
}
17+
18+
const content = new Blob(
19+
[
20+
'"Assessment Number","Assessment Name","Student Name","Student Username","Group","Status","Grading","Question Count","Questions Graded","Initial XP","XP Adjustment","Current XP (excl. bonus)","Max XP","Bonus XP"\n',
21+
...gradingOverviews.map(
22+
e =>
23+
[
24+
e.assessmentNumber,
25+
e.assessmentName,
26+
e.studentName,
27+
e.studentUsername,
28+
e.groupName,
29+
e.submissionStatus,
30+
e.gradingStatus,
31+
e.questionCount,
32+
e.gradedCount,
33+
e.initialXp,
34+
e.xpAdjustment,
35+
e.currentXp,
36+
e.maxXp,
37+
e.xpBonus
38+
]
39+
.map(field => `"${field}"`) // wrap each field in double quotes in case it contains a comma
40+
.join(',') + '\n'
41+
)
42+
],
43+
{ type: 'text/csv' }
44+
);
45+
const fileName = `SA submissions (${new Date().toISOString()}).csv`;
46+
47+
// code from https://github.com/ag-grid/ag-grid/blob/latest/grid-community-modules/csv-export/src/csvExport/downloader.ts
48+
const element = document.createElement('a');
49+
const url = win.URL.createObjectURL(content);
50+
element.setAttribute('href', url);
51+
element.setAttribute('download', fileName);
52+
element.style.display = 'none';
53+
document.body.appendChild(element);
54+
55+
element.dispatchEvent(
56+
new MouseEvent('click', {
57+
bubbles: false,
58+
cancelable: true,
59+
view: win
60+
})
61+
);
62+
63+
document.body.removeChild(element);
64+
65+
win.setTimeout(() => {
66+
win.URL.revokeObjectURL(url);
67+
}, 0);
68+
};

src/pages/academy/grading/Grading.tsx

Lines changed: 91 additions & 71 deletions
Original file line numberDiff line numberDiff line change
@@ -1,27 +1,58 @@
1-
import { NonIdealState, Spinner, SpinnerSize } from '@blueprintjs/core';
2-
import * as React from 'react';
1+
import '@tremor/react/dist/esm/tremor.css';
2+
3+
import { Icon as BpIcon, NonIdealState, Position, Spinner, SpinnerSize } from '@blueprintjs/core';
4+
import { IconNames } from '@blueprintjs/icons';
5+
import { Button, Card, Col, ColGrid, Flex, Text, Title } from '@tremor/react';
6+
import React, { useEffect, useState } from 'react';
37
import { useDispatch } from 'react-redux';
48
import { Navigate, useParams } from 'react-router';
59
import { fetchGradingOverviews } from 'src/commons/application/actions/SessionActions';
6-
import { useTypedSelector } from 'src/commons/utils/Hooks';
10+
import { Role } from 'src/commons/application/ApplicationTypes';
11+
import SimpleDropdown from 'src/commons/SimpleDropdown';
12+
import { useSession } from 'src/commons/utils/Hooks';
713
import { numberRegExp } from 'src/features/academy/AcademyTypes';
14+
import { exportGradingCSV, isSubmissionUngraded } from 'src/features/grading/GradingUtils';
815

916
import ContentDisplay from '../../../commons/ContentDisplay';
1017
import { convertParamToInt } from '../../../commons/utils/ParamParseHelper';
11-
import GradingDashboard from './subcomponents/GradingDashboard';
18+
import GradingSubmissionsTable from './subcomponents/GradingSubmissionsTable';
19+
import GradingSummary from './subcomponents/GradingSummary';
1220
import GradingWorkspace from './subcomponents/GradingWorkspace';
1321

1422
const Grading: React.FC = () => {
15-
const { courseId, gradingOverviews } = useTypedSelector(state => state.session);
23+
const {
24+
courseId,
25+
gradingOverviews,
26+
role,
27+
group,
28+
assessmentOverviews: assessments = []
29+
} = useSession();
1630
const params = useParams<{
1731
submissionId: string;
1832
questionId: string;
1933
}>();
2034

35+
const isAdmin = role === Role.Admin;
36+
const [showAllGroups, setShowAllGroups] = useState(isAdmin);
37+
const handleShowAllGroups = (value: boolean) => {
38+
// Admins will always see all groups regardless
39+
setShowAllGroups(isAdmin || value);
40+
};
41+
const groupOptions = [{ value: true, label: 'all groups' }];
42+
if (!isAdmin) {
43+
groupOptions.unshift({ value: false, label: 'my groups' });
44+
}
45+
2146
const dispatch = useDispatch();
22-
React.useEffect(() => {
23-
dispatch(fetchGradingOverviews(false));
24-
}, [dispatch]);
47+
useEffect(() => {
48+
dispatch(fetchGradingOverviews(!showAllGroups));
49+
}, [dispatch, role, showAllGroups]);
50+
51+
const [showAllSubmissions, setShowAllSubmissions] = useState(false);
52+
const showOptions = [
53+
{ value: false, label: 'ungraded' },
54+
{ value: true, label: 'all' }
55+
];
2556

2657
// If submissionId or questionId is defined but not numeric, redirect back to the Grading overviews page
2758
if (
@@ -49,79 +80,68 @@ const Grading: React.FC = () => {
4980
/>
5081
);
5182

52-
const data =
83+
const submissions =
5384
gradingOverviews?.map(e =>
5485
!e.studentName ? { ...e, studentName: '(user has yet to log in)' } : e
5586
) ?? [];
5687

57-
const exportCSV = () => {
58-
if (!gradingOverviews) return;
59-
60-
const win = document.defaultView || window;
61-
if (!win) {
62-
console.warn('There is no `window` associated with the current `document`');
63-
return;
64-
}
65-
66-
const content = new Blob(
67-
[
68-
'"Assessment Number","Assessment Name","Student Name","Student Username","Group","Status","Grading","Question Count","Questions Graded","Initial XP","XP Adjustment","Current XP (excl. bonus)","Max XP","Bonus XP"\n',
69-
...gradingOverviews.map(
70-
e =>
71-
[
72-
e.assessmentNumber,
73-
e.assessmentName,
74-
e.studentName,
75-
e.studentUsername,
76-
e.groupName,
77-
e.submissionStatus,
78-
e.gradingStatus,
79-
e.questionCount,
80-
e.gradedCount,
81-
e.initialXp,
82-
e.xpAdjustment,
83-
e.currentXp,
84-
e.maxXp,
85-
e.xpBonus
86-
]
87-
.map(field => `"${field}"`) // wrap each field in double quotes in case it contains a comma
88-
.join(',') + '\n'
89-
)
90-
],
91-
{ type: 'text/csv' }
92-
);
93-
const fileName = `SA submissions (${new Date().toISOString()}).csv`;
94-
95-
// code from https://github.com/ag-grid/ag-grid/blob/latest/grid-community-modules/csv-export/src/csvExport/downloader.ts
96-
const element = document.createElement('a');
97-
const url = win.URL.createObjectURL(content);
98-
element.setAttribute('href', url);
99-
element.setAttribute('download', fileName);
100-
element.style.display = 'none';
101-
document.body.appendChild(element);
102-
103-
element.dispatchEvent(
104-
new MouseEvent('click', {
105-
bubbles: false,
106-
cancelable: true,
107-
view: win
108-
})
109-
);
110-
111-
document.body.removeChild(element);
112-
113-
win.setTimeout(() => {
114-
win.URL.revokeObjectURL(url);
115-
}, 0);
116-
};
117-
11888
return (
11989
<ContentDisplay
12090
display={
12191
gradingOverviews === undefined ? (
12292
loadingDisplay
12393
) : (
124-
<GradingDashboard submissions={data} handleCsvExport={exportCSV} />
94+
<ColGrid numColsLg={8} gapX="gap-x-4" gapY="gap-y-2">
95+
<Col numColSpanLg={6}>
96+
<Card>
97+
<Flex justifyContent="justify-between">
98+
<Flex justifyContent="justify-start" spaceX="space-x-6">
99+
<Title>Submissions</Title>
100+
<Button
101+
variant="light"
102+
size="xs"
103+
icon={() => (
104+
<BpIcon icon={IconNames.EXPORT} style={{ marginRight: '0.5rem' }} />
105+
)}
106+
onClick={() => exportGradingCSV(gradingOverviews)}
107+
>
108+
Export to CSV
109+
</Button>
110+
</Flex>
111+
</Flex>
112+
<Flex justifyContent="justify-start" marginTop="mt-2" spaceX="space-x-2">
113+
<Text>Viewing</Text>
114+
<SimpleDropdown
115+
options={showOptions}
116+
defaultValue={showAllSubmissions}
117+
onClick={setShowAllSubmissions}
118+
popoverProps={{ position: Position.BOTTOM }}
119+
buttonProps={{ minimal: true, rightIcon: 'caret-down' }}
120+
/>
121+
<Text>submissions from</Text>
122+
<SimpleDropdown
123+
options={groupOptions}
124+
defaultValue={showAllGroups}
125+
onClick={handleShowAllGroups}
126+
popoverProps={{ position: Position.BOTTOM }}
127+
buttonProps={{ minimal: true, rightIcon: 'caret-down' }}
128+
/>
129+
</Flex>
130+
<GradingSubmissionsTable
131+
group={group}
132+
submissions={submissions.filter(
133+
s => showAllSubmissions || isSubmissionUngraded(s)
134+
)}
135+
/>
136+
</Card>
137+
</Col>
138+
139+
<Col numColSpanLg={2}>
140+
<Card hFull>
141+
<GradingSummary group={group} submissions={submissions} assessments={assessments} />
142+
</Card>
143+
</Col>
144+
</ColGrid>
125145
)
126146
}
127147
fullWidth={true}

src/pages/academy/grading/subcomponents/GradingDashboard.tsx

Lines changed: 0 additions & 73 deletions
This file was deleted.

0 commit comments

Comments
 (0)