-
Notifications
You must be signed in to change notification settings - Fork 3
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Testing for teachers when making exams (#1244)
* Teachers can test exams * Form for editing exam * little fixes and tests for testing and editing exams * little fix * fix eslint errors * Added translations and little fixes * Added queryClient * Fix for tests * Added css * little fix * Little css changes * Added translation * Little improvements * Added query --------- Co-authored-by: Maija Y <[email protected]>
- Loading branch information
Showing
32 changed files
with
1,315 additions
and
79 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
366 changes: 366 additions & 0 deletions
366
services/course-material/src/pages/[organizationSlug]/exams/testexam/[id].tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,366 @@ | ||
import { css } from "@emotion/css" | ||
import { useQuery, useQueryClient } from "@tanstack/react-query" | ||
import { addMinutes, differenceInSeconds, isPast, min, parseISO } from "date-fns" | ||
import React, { useCallback, useContext, useEffect, useReducer, useState } from "react" | ||
import { useTranslation } from "react-i18next" | ||
|
||
import ContentRenderer from "../../../../components/ContentRenderer" | ||
import Page from "../../../../components/Page" | ||
import ExamStartBanner from "../../../../components/exams/ExamStartBanner" | ||
import ExamTimer from "../../../../components/exams/ExamTimer" | ||
import ExamTimeOverModal from "../../../../components/modals/ExamTimeOverModal" | ||
import LayoutContext from "../../../../contexts/LayoutContext" | ||
import PageContext, { | ||
CoursePageDispatch, | ||
getDefaultPageState, | ||
} from "../../../../contexts/PageContext" | ||
import useTime from "../../../../hooks/useTime" | ||
import pageStateReducer from "../../../../reducers/pageStateReducer" | ||
import { | ||
Block, | ||
enrollInExam, | ||
fetchExamForTesting, | ||
resetExamProgress, | ||
updateShowExerciseAnswers, | ||
} from "../../../../services/backend" | ||
import Button from "../../../../shared-module/components/Button" | ||
import BreakFromCentered from "../../../../shared-module/components/Centering/BreakFromCentered" | ||
import ErrorBanner from "../../../../shared-module/components/ErrorBanner" | ||
import CheckBox from "../../../../shared-module/components/InputFields/CheckBox" | ||
import Spinner from "../../../../shared-module/components/Spinner" | ||
import HideTextInSystemTests from "../../../../shared-module/components/system-tests/HideTextInSystemTests" | ||
import { withSignedIn } from "../../../../shared-module/contexts/LoginStateContext" | ||
import useToastMutation from "../../../../shared-module/hooks/useToastMutation" | ||
import { baseTheme, fontWeights, headingFont } from "../../../../shared-module/styles" | ||
import { respondToOrLarger } from "../../../../shared-module/styles/respond" | ||
import dontRenderUntilQueryParametersReady, { | ||
SimplifiedUrlQuery, | ||
} from "../../../../shared-module/utils/dontRenderUntilQueryParametersReady" | ||
import withErrorBoundary from "../../../../shared-module/utils/withErrorBoundary" | ||
|
||
interface ExamProps { | ||
// "organizationSlug" | ||
query: SimplifiedUrlQuery<string> | ||
} | ||
|
||
const Exam: React.FC<React.PropsWithChildren<ExamProps>> = ({ query }) => { | ||
const { t, i18n } = useTranslation() | ||
const examId = query.id | ||
const [showExamAnswers, setShowExamAnswers] = useState<boolean>(false) | ||
const [pageState, pageStateDispatch] = useReducer( | ||
pageStateReducer, | ||
// We don't pass a refetch function here on purpose because refetching during an exam is risky because we don't want to accidentally lose unsubitted answers | ||
getDefaultPageState(undefined), | ||
) | ||
const now = useTime(5000) | ||
|
||
const queryClient = useQueryClient() | ||
|
||
const exam = useQuery({ | ||
queryKey: [`exam-page-testexam-${examId}-fetch-exam-for-testing`], | ||
queryFn: () => fetchExamForTesting(examId), | ||
}) | ||
|
||
const showAnswersMutation = useToastMutation( | ||
(showAnswers: boolean) => updateShowExerciseAnswers(examId, showAnswers), | ||
{ | ||
notify: false, | ||
}, | ||
{ | ||
onSuccess: async () => { | ||
await queryClient.refetchQueries() | ||
}, | ||
}, | ||
) | ||
|
||
const resetExamMutation = useToastMutation( | ||
() => resetExamProgress(examId), | ||
{ | ||
notify: false, | ||
}, | ||
{ | ||
onSuccess: async () => { | ||
showAnswersMutation.mutate(false) | ||
await queryClient.refetchQueries() | ||
}, | ||
}, | ||
) | ||
|
||
useEffect(() => { | ||
if (exam.isError) { | ||
// eslint-disable-next-line i18next/no-literal-string | ||
pageStateDispatch({ type: "setError", payload: exam.error }) | ||
} else if (exam.isSuccess && exam.data.enrollment_data.tag === "EnrolledAndStarted") { | ||
pageStateDispatch({ | ||
// eslint-disable-next-line i18next/no-literal-string | ||
type: "setData", | ||
payload: { | ||
pageData: exam.data.enrollment_data.page, | ||
instance: null, | ||
settings: null, | ||
exam: exam.data, | ||
isTest: false, | ||
}, | ||
}) | ||
setShowExamAnswers(exam.data.enrollment_data.enrollment.show_exercise_answers ?? false) | ||
} else { | ||
// eslint-disable-next-line i18next/no-literal-string | ||
pageStateDispatch({ type: "setLoading" }) | ||
} | ||
}, [exam.isError, exam.isSuccess, exam.data, exam.error]) | ||
|
||
useEffect(() => { | ||
if (!exam.data) { | ||
return | ||
} | ||
if (i18n.language !== exam.data.language) { | ||
i18n.changeLanguage(exam.data.language) | ||
} | ||
}) | ||
|
||
const layoutContext = useContext(LayoutContext) | ||
useEffect(() => { | ||
layoutContext.setOrganizationSlug(query.organizationSlug) | ||
}, [layoutContext, query.organizationSlug]) | ||
|
||
const handleRefresh = useCallback(async () => { | ||
await exam.refetch() | ||
}, [exam]) | ||
|
||
const handleTimeOverModalClose = useCallback(async () => { | ||
await handleRefresh() | ||
}, [handleRefresh]) | ||
|
||
const handleResetProgress = useCallback(async () => { | ||
resetExamMutation.mutate() | ||
}, [resetExamMutation]) | ||
|
||
const handleShowAnswers = useCallback(async () => { | ||
setShowExamAnswers(!showExamAnswers) | ||
showAnswersMutation.mutate(!showExamAnswers) | ||
}, [showAnswersMutation, showExamAnswers]) | ||
|
||
if (exam.isPending) { | ||
return <Spinner variant="medium" /> | ||
} | ||
|
||
if (exam.isError) { | ||
return <ErrorBanner variant={"readOnly"} error={exam.error} /> | ||
} | ||
|
||
const examInfo = ( | ||
<BreakFromCentered sidebar={false}> | ||
<div | ||
className={css` | ||
background: #f6f6f6; | ||
padding: 20px; | ||
margin-bottom: 49px; | ||
${respondToOrLarger.sm} { | ||
padding-top: 81px; | ||
padding-left: 128px; | ||
padding-right: 128px; | ||
padding-bottom: 83px; | ||
} | ||
`} | ||
> | ||
<div | ||
className={css` | ||
font-family: | ||
Josefin Sans, | ||
sans-serif; | ||
font-size: 30px; | ||
font-style: normal; | ||
font-weight: 600; | ||
line-height: 30px; | ||
letter-spacing: 0em; | ||
text-align: left; | ||
color: #333333; | ||
text-transform: uppercase; | ||
`} | ||
> | ||
{exam.data.name} | ||
</div> | ||
<div | ||
className={css` | ||
font-family: Lato, sans-serif; | ||
font-size: 20px; | ||
font-style: normal; | ||
font-weight: 500; | ||
line-height: 26px; | ||
letter-spacing: 0em; | ||
text-align: left; | ||
color: #353535; | ||
`} | ||
> | ||
{(exam.data.enrollment_data.tag === "NotEnrolled" || | ||
exam.data.enrollment_data.tag === "NotYetStarted") && ( | ||
<> | ||
<div> | ||
<HideTextInSystemTests | ||
text={ | ||
exam.data.starts_at | ||
? t("exam-can-be-started-after", { | ||
"starts-at": exam.data.starts_at.toLocaleString(), | ||
}) | ||
: t("exam-no-start-time") | ||
} | ||
testPlaceholder={t("exam-can-be-started-after", { | ||
"starts-at": "1/1/1970, 0:00:00 AM", | ||
})} | ||
/> | ||
</div> | ||
<div> | ||
<HideTextInSystemTests | ||
text={ | ||
exam.data.ends_at | ||
? t("exam-submissions-not-accepted-after", { | ||
"ends-at": exam.data.ends_at.toLocaleString(), | ||
}) | ||
: t("exam-no-end-time") | ||
} | ||
testPlaceholder={t("exam-submissions-not-accepted-after", { | ||
"ends-at": "1/1/1970, 7:00:00 PM", | ||
})} | ||
/> | ||
</div> | ||
<div> {t("exam-time-to-complete", { "time-minutes": exam.data.time_minutes })}</div> | ||
</> | ||
)} | ||
</div> | ||
</div> | ||
</BreakFromCentered> | ||
) | ||
if ( | ||
exam.data.enrollment_data.tag === "NotEnrolled" || | ||
exam.data.enrollment_data.tag === "NotYetStarted" | ||
) { | ||
return ( | ||
<> | ||
{examInfo} | ||
<div id="exam-instructions"> | ||
<ExamStartBanner | ||
onStart={async () => { | ||
await enrollInExam(examId, true) | ||
exam.refetch() | ||
}} | ||
examEnrollmentData={exam.data.enrollment_data} | ||
examHasStarted={true} | ||
examHasEnded={exam.data.ends_at ? isPast(exam.data.ends_at) : false} | ||
timeMinutes={exam.data.time_minutes} | ||
> | ||
<div | ||
id="maincontent" | ||
className={css` | ||
opacity: 80%; | ||
`} | ||
> | ||
<ContentRenderer | ||
data={(exam.data.instructions as Array<Block<unknown>>) ?? []} | ||
editing={false} | ||
selectedBlockId={null} | ||
setEdits={(map) => map} | ||
isExam={false} | ||
/> | ||
</div> | ||
</ExamStartBanner> | ||
</div> | ||
</> | ||
) | ||
} | ||
|
||
if (exam.data.enrollment_data.tag === "StudentTimeUp") { | ||
return ( | ||
<> | ||
{examInfo} | ||
<div>{t("exam-time-up", { "ends-at": exam.data.ends_at.toLocaleString() })}</div> | ||
</> | ||
) | ||
} | ||
|
||
const endsAt = exam.data.ends_at | ||
? min([ | ||
addMinutes(exam.data.enrollment_data.enrollment.started_at, exam.data.time_minutes), | ||
exam.data.ends_at, | ||
]) | ||
: addMinutes(exam.data.enrollment_data.enrollment.started_at, exam.data.time_minutes) | ||
const secondsLeft = differenceInSeconds(endsAt, now) | ||
return ( | ||
<> | ||
<CoursePageDispatch.Provider value={pageStateDispatch}> | ||
<PageContext.Provider value={pageState}> | ||
<ExamTimeOverModal | ||
disabled={exam.data.ended} | ||
secondsLeft={secondsLeft} | ||
onClose={handleTimeOverModalClose} | ||
/> | ||
{examInfo} | ||
<ExamTimer | ||
startedAt={parseISO(exam.data.enrollment_data.enrollment.started_at)} | ||
endsAt={endsAt} | ||
secondsLeft={secondsLeft} | ||
/> | ||
{secondsLeft < 10 * 60 && ( | ||
<div | ||
className={css` | ||
background-color: ${baseTheme.colors.yellow[100]}; | ||
color: black; | ||
padding: 0.7rem 1rem; | ||
margin: 1rem 0; | ||
border: 1px solid ${baseTheme.colors.yellow[300]}; | ||
`} | ||
> | ||
<div>{t("exam-time-running-out-soon-help-text")}</div> | ||
</div> | ||
)} | ||
<Page onRefresh={handleRefresh} organizationSlug={query.organizationSlug} /> | ||
</PageContext.Provider> | ||
</CoursePageDispatch.Provider> | ||
<> | ||
{exam.data?.enrollment_data.enrollment.is_teacher_testing && ( | ||
<div | ||
className={css` | ||
display: flex; | ||
flex-direction: row; | ||
align-items: baseline; | ||
gap: 20px; | ||
span { | ||
font-size: 20px; | ||
font-family: ${headingFont}; | ||
font-weight: ${fontWeights.semibold}; | ||
color: ${baseTheme.colors.gray[700]}; | ||
} | ||
`} | ||
> | ||
<Button | ||
className={css` | ||
font-size: 20px !important; | ||
font-family: ${headingFont} !important; | ||
`} | ||
variant="primary" | ||
size="medium" | ||
transform="capitalize" | ||
onClick={() => { | ||
handleResetProgress() | ||
}} | ||
> | ||
{t("button-text-reset-exam-progress")} | ||
</Button> | ||
<CheckBox | ||
label={t("show-answers")} | ||
checked={showExamAnswers} | ||
onChange={() => { | ||
handleShowAnswers() | ||
}} | ||
/> | ||
</div> | ||
)} | ||
</> | ||
</> | ||
) | ||
} | ||
|
||
export default withErrorBoundary(withSignedIn(dontRenderUntilQueryParametersReady(Exam))) |
Oops, something went wrong.