Skip to content
45 changes: 45 additions & 0 deletions src/commons/SimpleDropdown.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { Button, Menu, MenuItem } from '@blueprintjs/core';
import { Popover2 } from '@blueprintjs/popover2';
import { useState } from 'react';

type OptionType = { value: any; label: string };
type Props<T extends OptionType> = {
options: T[];
defaultValue?: T['value'];
onClick?: (v: T['value']) => void;
buttonProps?: Partial<React.ComponentProps<typeof Button>>;
popoverProps?: Partial<React.ComponentProps<typeof Popover2>>;
};

function SimpleDropdown<T extends OptionType>(props: Props<T>) {
const { options, defaultValue, onClick, buttonProps, popoverProps } = props;
const [selectedOption, setSelectedOption] = useState(defaultValue);

const handleClick = (value: T['value']) => {
setSelectedOption(value);
onClick?.(value);
};

const buttonLabel = () => {
const option = options.find(({ value }) => value === selectedOption);
return option?.label;
};

return (
<Popover2
{...popoverProps}
interactionKind="click"
content={
<Menu>
{options.map(({ value, label }) => (
<MenuItem text={label} onClick={() => handleClick(value)} />
))}
</Menu>
}
>
<Button {...buttonProps}>{buttonLabel()}</Button>
</Popover2>
);
}

export default SimpleDropdown;
68 changes: 68 additions & 0 deletions src/features/grading/GradingUtils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import { GradingStatuses } from 'src/commons/assessment/AssessmentTypes';

import { GradingOverview } from './GradingTypes';

export const isSubmissionUngraded = (s: GradingOverview): boolean => {
return s.submissionStatus !== 'submitted' || s.gradingStatus !== GradingStatuses.graded;
};

export const exportGradingCSV = (gradingOverviews: GradingOverview[] | undefined) => {
if (!gradingOverviews) return;

const win = document.defaultView || window;
if (!win) {
console.warn('There is no `window` associated with the current `document`');
return;
}

const content = new Blob(
[
'"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',
...gradingOverviews.map(
e =>
[
e.assessmentNumber,
e.assessmentName,
e.studentName,
e.studentUsername,
e.groupName,
e.submissionStatus,
e.gradingStatus,
e.questionCount,
e.gradedCount,
e.initialXp,
e.xpAdjustment,
e.currentXp,
e.maxXp,
e.xpBonus
]
.map(field => `"${field}"`) // wrap each field in double quotes in case it contains a comma
.join(',') + '\n'
)
],
{ type: 'text/csv' }
);
const fileName = `SA submissions (${new Date().toISOString()}).csv`;

// code from https://github.com/ag-grid/ag-grid/blob/latest/grid-community-modules/csv-export/src/csvExport/downloader.ts
const element = document.createElement('a');
const url = win.URL.createObjectURL(content);
element.setAttribute('href', url);
element.setAttribute('download', fileName);
element.style.display = 'none';
document.body.appendChild(element);

element.dispatchEvent(
new MouseEvent('click', {
bubbles: false,
cancelable: true,
view: win
})
);

document.body.removeChild(element);

win.setTimeout(() => {
win.URL.revokeObjectURL(url);
}, 0);
};
162 changes: 91 additions & 71 deletions src/pages/academy/grading/Grading.tsx
Original file line number Diff line number Diff line change
@@ -1,27 +1,58 @@
import { NonIdealState, Spinner, SpinnerSize } from '@blueprintjs/core';
import * as React from 'react';
import '@tremor/react/dist/esm/tremor.css';

import { Icon as BpIcon, NonIdealState, Position, Spinner, SpinnerSize } from '@blueprintjs/core';
import { IconNames } from '@blueprintjs/icons';
import { Button, Card, Col, ColGrid, Flex, Text, Title } from '@tremor/react';
import React, { useEffect, useState } from 'react';
import { useDispatch } from 'react-redux';
import { Navigate, useParams } from 'react-router';
import { fetchGradingOverviews } from 'src/commons/application/actions/SessionActions';
import { useTypedSelector } from 'src/commons/utils/Hooks';
import { Role } from 'src/commons/application/ApplicationTypes';
import SimpleDropdown from 'src/commons/SimpleDropdown';
import { useSession } from 'src/commons/utils/Hooks';
import { numberRegExp } from 'src/features/academy/AcademyTypes';
import { exportGradingCSV, isSubmissionUngraded } from 'src/features/grading/GradingUtils';

import ContentDisplay from '../../../commons/ContentDisplay';
import { convertParamToInt } from '../../../commons/utils/ParamParseHelper';
import GradingDashboard from './subcomponents/GradingDashboard';
import GradingSubmissionsTable from './subcomponents/GradingSubmissionsTable';
import GradingSummary from './subcomponents/GradingSummary';
import GradingWorkspace from './subcomponents/GradingWorkspace';

const Grading: React.FC = () => {
const { courseId, gradingOverviews } = useTypedSelector(state => state.session);
const {
courseId,
gradingOverviews,
role,
group,
assessmentOverviews: assessments = []
} = useSession();
const params = useParams<{
submissionId: string;
questionId: string;
}>();

const isAdmin = role === Role.Admin;
const [showAllGroups, setShowAllGroups] = useState(isAdmin);
const handleShowAllGroups = (value: boolean) => {
// Admins will always see all groups regardless
setShowAllGroups(isAdmin || value);
};
const groupOptions = [{ value: true, label: 'all groups' }];
if (!isAdmin) {
groupOptions.unshift({ value: false, label: 'my groups' });
}

const dispatch = useDispatch();
React.useEffect(() => {
dispatch(fetchGradingOverviews(false));
}, [dispatch]);
useEffect(() => {
dispatch(fetchGradingOverviews(!showAllGroups));
}, [dispatch, role, showAllGroups]);

const [showAllSubmissions, setShowAllSubmissions] = useState(false);
const showOptions = [
{ value: false, label: 'ungraded' },
{ value: true, label: 'all' }
];

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

const data =
const submissions =
gradingOverviews?.map(e =>
!e.studentName ? { ...e, studentName: '(user has yet to log in)' } : e
) ?? [];

const exportCSV = () => {
if (!gradingOverviews) return;

const win = document.defaultView || window;
if (!win) {
console.warn('There is no `window` associated with the current `document`');
return;
}

const content = new Blob(
[
'"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',
...gradingOverviews.map(
e =>
[
e.assessmentNumber,
e.assessmentName,
e.studentName,
e.studentUsername,
e.groupName,
e.submissionStatus,
e.gradingStatus,
e.questionCount,
e.gradedCount,
e.initialXp,
e.xpAdjustment,
e.currentXp,
e.maxXp,
e.xpBonus
]
.map(field => `"${field}"`) // wrap each field in double quotes in case it contains a comma
.join(',') + '\n'
)
],
{ type: 'text/csv' }
);
const fileName = `SA submissions (${new Date().toISOString()}).csv`;

// code from https://github.com/ag-grid/ag-grid/blob/latest/grid-community-modules/csv-export/src/csvExport/downloader.ts
const element = document.createElement('a');
const url = win.URL.createObjectURL(content);
element.setAttribute('href', url);
element.setAttribute('download', fileName);
element.style.display = 'none';
document.body.appendChild(element);

element.dispatchEvent(
new MouseEvent('click', {
bubbles: false,
cancelable: true,
view: win
})
);

document.body.removeChild(element);

win.setTimeout(() => {
win.URL.revokeObjectURL(url);
}, 0);
};

return (
<ContentDisplay
display={
gradingOverviews === undefined ? (
loadingDisplay
) : (
<GradingDashboard submissions={data} handleCsvExport={exportCSV} />
<ColGrid numColsLg={8} gapX="gap-x-4" gapY="gap-y-2">
<Col numColSpanLg={6}>
<Card>
<Flex justifyContent="justify-between">
<Flex justifyContent="justify-start" spaceX="space-x-6">
<Title>Submissions</Title>
<Button
variant="light"
size="xs"
icon={() => (
<BpIcon icon={IconNames.EXPORT} style={{ marginRight: '0.5rem' }} />
)}
onClick={() => exportGradingCSV(gradingOverviews)}
>
Export to CSV
</Button>
</Flex>
</Flex>
<Flex justifyContent="justify-start" marginTop="mt-2" spaceX="space-x-2">
<Text>Viewing</Text>
<SimpleDropdown
options={showOptions}
defaultValue={showAllSubmissions}
onClick={setShowAllSubmissions}
popoverProps={{ position: Position.BOTTOM }}
buttonProps={{ minimal: true, rightIcon: 'caret-down' }}
/>
<Text>submissions from</Text>
<SimpleDropdown
options={groupOptions}
defaultValue={showAllGroups}
onClick={handleShowAllGroups}
popoverProps={{ position: Position.BOTTOM }}
buttonProps={{ minimal: true, rightIcon: 'caret-down' }}
/>
</Flex>
<GradingSubmissionsTable
group={group}
submissions={submissions.filter(
s => showAllSubmissions || isSubmissionUngraded(s)
)}
/>
</Card>
</Col>

<Col numColSpanLg={2}>
<Card hFull>
<GradingSummary group={group} submissions={submissions} assessments={assessments} />
</Card>
</Col>
</ColGrid>
)
}
fullWidth={true}
Expand Down
73 changes: 0 additions & 73 deletions src/pages/academy/grading/subcomponents/GradingDashboard.tsx

This file was deleted.