Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: Gradebook #98

Merged
merged 5 commits into from
Aug 6, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -569,7 +569,7 @@ const ContentToolbar = () => {
aria-label="Generate Quizzes"
onClick={() => setOpenQuizGenerator(true)}
>
<IconWand size={20} stroke={1.7} />{" "}
<IconWand size={20} stroke={1.7} color="blue" />{" "}
<Trans>Quiz Generator (AI)</Trans>
</Toolbar.ToolbarButton>
</Tooltip>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { MixerHorizontalIcon } from "@radix-ui/react-icons";

import Modal from "components/base/Modal";
import { GradeMethod } from "graphql/types";
import React from "react";

export type TestDurationAttributeProps = {
gradeMethod: GradeMethod;
Expand Down Expand Up @@ -59,6 +60,22 @@ const GradeMethodAttribute = ({
</Modal>
)}
</Flex>
{GradeMethod.AUTO === gradeMethod && (
<Text color="gray">
<Trans>
Students will receive the result and the correct answers immediately
after completing their submissions.
</Trans>
</Text>
)}
{GradeMethod.MANUAL === gradeMethod && (
<Text color="gray">
<Trans>
Students will receive the result and the correct answers after the
teacher's feedback.
</Trans>
</Text>
)}
</div>
);
};
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,190 @@
import React from "react";
import { Trans } from "@lingui/macro";
import { Button, Heading, Separator, Table, Text } from "@radix-ui/themes";
import { CSVLink } from "react-csv";

import useDocumentStore, { ISpaceDocument } from "store/DocumentStore";
import { DocumentType, Role } from "graphql/types";
import {
LeftSideContainer,
LeftSideContentWrapper,
LeftSideHeaderWrapper,
} from "./shared";
import { ISpaceMember, useGetSpaceMembers } from "store/SpaceMembeStore";
import UserBasicInformation from "components/UserBasicInformation";
import useSpaceStore from "../../../../store/SpaceStore";
import { formatTimestamp, getNowAsSec } from "../../../../util/Time";

const Gradebook = () => {
const spaceDocuments = useDocumentStore((state) => state.spaceDocuments);
const sortedDocs = flatDocuments(spaceDocuments, null).filter(
(doc) => doc.documentType === DocumentType.ASSIGNMENT,
);
const { members } = useGetSpaceMembers(Role.STUDENT);

return (
<LeftSideContainer>
<LeftSideHeaderWrapper>
<div style={{ flex: 1, paddingLeft: 5 }}>
<Heading size="5">
<Trans>Gradebook</Trans>
</Heading>
</div>
</LeftSideHeaderWrapper>
<Separator style={{ width: "100%" }} />
<LeftSideContentWrapper style={{ padding: 20 }}>
<div style={{ display: "flex" }}>
<div style={{ flex: 1 }} />
<div>
<DownloadGradeBookCSV members={members} assignments={sortedDocs} />
</div>
</div>
<Table.Root variant="surface">
<Table.Header>
<Table.Row>
<Table.ColumnHeaderCell>
<Trans>Student</Trans>
</Table.ColumnHeaderCell>
<Table.ColumnHeaderCell minWidth="120px">
<Trans>Final Grade</Trans>
</Table.ColumnHeaderCell>
{sortedDocs.map((document) => (
<Table.ColumnHeaderCell key={document.id}>
<Text weight="medium">{document.title}</Text>
</Table.ColumnHeaderCell>
))}
</Table.Row>
</Table.Header>

<Table.Body>
{members.map((member) => (
<Table.Row key={member.userId}>
<Table.RowHeaderCell>
<UserBasicInformation
name={member.user.name}
avatar={member.user.avatar?.publicUrl}
email={member.user.email}
/>
</Table.RowHeaderCell>
<Table.RowHeaderCell>
{getFinalGradeOfStudent(sortedDocs, member.userId).toFixed(2)}
</Table.RowHeaderCell>
{sortedDocs.map((document) => (
<Table.RowHeaderCell key={document.id}>
{getLatestSubmissionByUserId(document, member.userId)
?.finalGrade || 0}
</Table.RowHeaderCell>
))}
</Table.Row>
))}
</Table.Body>
</Table.Root>
</LeftSideContentWrapper>
</LeftSideContainer>
);
};

export type DownloadGradebookCSVProps = {
assignments: ISpaceDocument[];
members: ISpaceMember[];
};

const DownloadGradeBookCSV = ({
assignments,
members,
}: DownloadGradebookCSVProps) => {
const spaceName = useSpaceStore((state) => state.space?.name);
const today = formatTimestamp(getNowAsSec());
const csvData = [
[
"Student",
"Email",
"Final Grade",
...assignments.map((assignment) => assignment.title),
],
];

members.forEach((member) => {
const row = [
member.user.name,
member.user.email,
getFinalGradeOfStudent(assignments, member.userId).toFixed(2),
...assignments.map((assignment) => {
const latestSubmission = getLatestSubmissionByUserId(
assignment,
member.userId,
);
if (latestSubmission) return latestSubmission.finalGrade.toFixed(2);

return "0";
}),
];
csvData.push(row);
});

return (
<CSVLink
data={csvData}
target={"_blank"}
filename={`Gradebook_${spaceName}_${today}`}
>
<Button>
<Trans>Export to CSV</Trans>
</Button>
</CSVLink>
);
};

const flatDocuments = (
spaceDocuments: ISpaceDocument[],
parentId: string | null,
): ISpaceDocument[] => {
const res = [];

const sortedDocs = spaceDocuments
.filter((doc) => doc.parentId === parentId)
.sort((docA, docB) => docA.index - docB.index);
res.push(...sortedDocs);

sortedDocs.forEach((sortedDoc) => {
res.push(...flatDocuments(spaceDocuments, sortedDoc.id));
});

return res;
};

const getLatestSubmissionByUserId = (
spaceDocument: ISpaceDocument,
userId: number,
) => {
const sortedSubmissions = spaceDocument.assignment?.submissions
?.filter((submission) => submission.userId === userId)
.sort(
(submissionA, submissionB) =>
submissionB.attemptNumber - submissionA.attemptNumber,
);

if (sortedSubmissions.length > 0) {
return sortedSubmissions[0];
}
};

const getFinalGradeOfStudent = (
sortedDocs: ISpaceDocument[],
userId: number,
) => {
if (sortedDocs.length === 0) return 0;

let finalGrade = 0;

sortedDocs.forEach((sortedDoc) => {
const latestSubmission = getLatestSubmissionByUserId(sortedDoc, userId);
if (latestSubmission) {
finalGrade += latestSubmission.finalGrade;
}
});

return finalGrade / sortedDocs.length;
};

export default Gradebook;
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,11 @@ import useDocumentStore from "store/DocumentStore";
import usePermission from "hook/UsePermission";
import { DocumentType, SpaceActionPermission } from "graphql/types";
import CreateContentButton from "components/common/LearningModuleDnd/CreateContentButton";
import {
LeftSideContainer,
LeftSideContentWrapper,
LeftSideHeaderWrapper,
} from "./shared";

const SpaceDocumentList = () => {
const allow = usePermission();
Expand All @@ -21,23 +26,8 @@ const SpaceDocumentList = () => {
const canAddContent = allow(SpaceActionPermission.MANAGE_SPACE_CONTENT);

return (
<div
style={{
display: "flex",
flexDirection: "column",
height: "100vh",
overflowY: "auto",
}}
>
<div
style={{
display: "flex",
alignItems: "center",
height: "50px",
paddingLeft: 10,
paddingRight: 10,
}}
>
<LeftSideContainer>
<LeftSideHeaderWrapper>
<div style={{ flex: 1, paddingLeft: 5 }}>
<Heading size="5">
<Trans>Content</Trans>
Expand All @@ -50,28 +40,19 @@ const SpaceDocumentList = () => {
</Button>
</CreateContentButton>
)}
</div>
</LeftSideHeaderWrapper>
<Separator style={{ width: "100%" }} />
<ListModule>
<LeftSideContentWrapper>
<LearningModuleDnd
docs={spaceDocuments}
keyword={""}
TreeItemComponent={LessonItemDnd}
defaultCollapsed={true}
parentId={null}
/>
</ListModule>
</div>
</LeftSideContentWrapper>
</LeftSideContainer>
);
};

export default SpaceDocumentList;

const ListModule = styled.div`
overflow: auto;
display: flex;
flex-direction: column;
gap: 5px;
flex: 1;
padding: 5px;
`;
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { useKeyPress } from "ahooks";

import SpaceDocumentList from "components/Document/LeftSide/LeftSecondarySide/SpaceDocumentList";
import useUIStore, { LeftSideBarOptions } from "store/UIStore";
import Gradebook from "./Gradebook";

const LeftSecondarySide = () => {
const ref = useRef<HTMLDivElement>();
Expand All @@ -24,6 +25,7 @@ const LeftSecondarySide = () => {
return (
<Container ref={ref} $hide={!expanded}>
{leftSidebar === LeftSideBarOptions.Content && <SpaceDocumentList />}
{leftSidebar === LeftSideBarOptions.Gradebook && <Gradebook />}
</Container>
);
};
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import styled from "styled-components";

export const LeftSideContainer = styled.div`
display: flex;
flex-direction: column;
height: 100vh;
overflow-y: auto;
`;

export const LeftSideHeaderWrapper = styled.div`
display: flex;
align-items: center;
height: 50px;
padding-left: 10px;
padding-right: 10px;
`;

export const LeftSideContentWrapper = styled.div`
overflow: auto;
display: flex;
flex-direction: column;
gap: 5px;
flex: 1;
padding: 5px;
`;
Loading
Loading