Skip to content

Commit

Permalink
feat: implement horizontal marking (#36)
Browse files Browse the repository at this point in the history
* feat: implement basic horizontal marking pane visual concept

* feat: implement correct handling of horizontal marking section checkboxes

* feat: implement All/None buttons

* feat: disable all/none button conditionally

* feat: implement filtering of question/part/section by visibility

* refactor: merge update-by-question and update-by-part

* fix: use unique keys

* feat: reflect horizontal marking in scrollspy

* refactor: use keys to extract only section number

* refactor: reduce code duplications

* refactor: improve naming and use Boolean function

* style: use pointer cursor for headers and change colours of all/none buttons
  • Loading branch information
procaconsul authored Oct 8, 2024
1 parent 9561800 commit 1c6e590
Show file tree
Hide file tree
Showing 7 changed files with 270 additions and 99 deletions.
13 changes: 11 additions & 2 deletions src/hooks/marking.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { instanceToPlain, plainToInstance } from 'class-transformer'
import { groupBy, mapValues } from 'lodash'
import { entries, flatMap, groupBy, keys, map, mapValues } from 'lodash'
import { useCallback, useEffect, useMemo, useState } from 'react'

import axiosInstance from '../api/axiosInstance'
Expand Down Expand Up @@ -33,7 +33,16 @@ export const useQuestions = () => {
.then(({ data }) => setQuestions(mapValues(data, (q) => plainToInstance(Question, q))))
.finally(() => setQuestionsAreLoaded(true))
}, [assessmentID])
return { questions, questionsAreLoaded }

const allSectionIDs: string[] = useMemo(
() =>
flatMap(entries(questions), ([qn, q]) =>
flatMap(entries(q.parts), ([pn, p]) => map(keys(p.sections), (sn) => `${qn}-${pn}-${sn}`))
),
[questions]
)

return { questions, allSectionIDs, questionsAreLoaded }
}

export const useMarks = () => {
Expand Down
15 changes: 15 additions & 0 deletions src/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,21 @@ ul.list {
}
}

.clickable {
cursor: pointer;
}

/* Button group */
.button-group .rt-Button:first-child {
box-shadow: none;
border-radius: var(--radius-2) 0 0 var(--radius-2);
}

.button-group .rt-Button:last-child {
box-shadow: none;
border-radius: 0 var(--radius-2) var(--radius-2) 0;
}

/* Global RadixUI Themes override */
.rt-Card {
--card-padding: 0;
Expand Down
114 changes: 114 additions & 0 deletions src/pages/MarkingPage/HorizontalMarkingPane.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
import { MixerHorizontalIcon } from '@radix-ui/react-icons'
import {
Box,
Button,
CheckboxGroup,
Flex,
Grid,
Heading,
Popover,
Separator,
} from '@radix-ui/themes'
import { fromPairs, keys, mapValues, pickBy, size } from 'lodash'
import { FC, useEffect, useState } from 'react'

import '../../index.css'
import { Question } from '../../types/exam'
import { hasPrefix, numberToLetter, numberToRoman } from '../../utils/common'

interface HorizontalMarkingPaneProps {
questions: Record<number, Question>
sectionIDs: string[]
onActiveSectionsUpdate: (sectionIDs: string[]) => void
}

const HorizontalMarkingPane: FC<HorizontalMarkingPaneProps> = ({
questions,
sectionIDs,
onActiveSectionsUpdate,
}) => {
const [horizontalMarkingState, setHorizontalMarkingState] = useState<Record<string, boolean>>(
fromPairs(sectionIDs.map((id) => [id, true]))
)
useEffect(() => {
onActiveSectionsUpdate(keys(pickBy(horizontalMarkingState, Boolean)))
}, [horizontalMarkingState, onActiveSectionsUpdate])

function handleUpdateAll(value: boolean) {
setHorizontalMarkingState((current) => mapValues(current, () => value))
}

function handleBulkToggleByPrefix(prefix: string) {
setHorizontalMarkingState((current) => {
let flippedState = !hasPrefix(keys(pickBy(current, Boolean)), prefix)
return mapValues(current, (v, k) => (k.startsWith(`${prefix}-`) ? flippedState : v))
})
}

function handleSectionSelectionToggle(partID: string, selectedIDs: string[]) {
setHorizontalMarkingState((current) =>
mapValues(current, (v, k) => (k.startsWith(`${partID}-`) ? selectedIDs.includes(k) : v))
)
}

return (
<Popover.Root>
<Popover.Trigger>
<Button variant="soft">
<MixerHorizontalIcon width="16" height="16" />
Horizontal Marking
</Button>
</Popover.Trigger>
<Popover.Content minWidth="20vw">
<Grid gap="5" columns={size(questions).toString()}>
{Object.entries(questions).map(([q, question]) => (
<Box key={q} p="2">
<Heading className="clickable" onClick={() => handleBulkToggleByPrefix(q)}>
Question {q}
</Heading>
<Separator size="4" />
{Object.entries(question.parts).map(([p, part]) => {
const partID = `${q}-${p}`
return (
<Box key={partID} p="1">
<Heading
className="clickable"
size="5"
as="h2"
onClick={() => handleBulkToggleByPrefix(partID)}
>
Part {numberToLetter(Number(p))}
</Heading>
<Box p="1">
<CheckboxGroup.Root
value={keys(pickBy(horizontalMarkingState, Boolean))}
onValueChange={(vs) => handleSectionSelectionToggle(partID, vs)}
>
{Object.keys(part.sections).map((s) => {
const sectionID = `${q}-${p}-${s}`
return (
<CheckboxGroup.Item key={sectionID} value={sectionID}>
Section {numberToRoman(Number(s))}
</CheckboxGroup.Item>
)
})}
</CheckboxGroup.Root>
</Box>
</Box>
)
})}
</Box>
))}
</Grid>
<Flex justify="center" align="center" className="button-group">
<Button onClick={() => handleUpdateAll(true)}>All</Button>
<Button color="gray" onClick={() => handleUpdateAll(false)}>
None
</Button>
</Flex>
</Popover.Content>
</Popover.Root>
)
}

export default HorizontalMarkingPane
127 changes: 71 additions & 56 deletions src/pages/MarkingPage/MarkableSubmission.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,14 @@ import Section from '../../components/questionStructure/Section'
import { ReadOnlyTaskFactory, TaskProps } from '../../components/questionStructure/Task/readonly'
import { Answer, Question as QuestionType } from '../../types/exam'
import { MarkRoot, Student } from '../../types/marking'
import { hasPrefix } from '../../utils/common'
import MarkInputPanel from './MarkInputPanel'
import QuestionHeader from './QuestionHeader'

interface MarkableSubmissionProps {
student: Student
questions: Record<number, QuestionType>
visibleSectionIDs: string[]
student: Student
lookupMark: (student: string, question: number, part: number, section: number) => MarkRoot
lookupAnswer: (
student: string,
Expand All @@ -27,71 +29,84 @@ interface MarkableSubmissionProps {
}

const MarkableSubmission: FC<MarkableSubmissionProps> = ({
student,
questions,
visibleSectionIDs,
student,
lookupMark,
lookupAnswer,
saveMark,
}) => {
return (
<Box>
{Object.entries(questions).map(([q_, question]) => {
const q = Number(q_)
return (
<Box key={q_}>
<Flex direction="column" gap="4" px="6">
<QuestionHeader number={q_} title={question.title} />
<Question instructions={question.instructions}>
{Object.entries(question.parts).map(([p_, part]) => {
const p = Number(p_)
return (
<Part
key={p}
partId={p}
description={part.instructions}
marksContribution={sum(map(part.sections, 'maximumMark'))}
>
{Object.entries(part.sections).map(([s_, section], i) => {
const s = Number(s_)
const sectionId = `${q}-${p}-${s}`
const mark = lookupMark(student.username, q, p, s)
return (
<Section key={s} sectionId={sectionId} description={section.instructions}>
{section.tasks.map((task, t_) => {
const t = t_ + 1
const answer = lookupAnswer(student.username, q, p, s, t)
{Object.entries(questions)
.filter(([q, _]) => hasPrefix(visibleSectionIDs, `${q}-`))
.map(([q_, question]) => {
const q = Number(q_)
return (
<Box key={q_}>
<Flex direction="column" gap="4" px="6">
<QuestionHeader number={q_} title={question.title} />
<Question instructions={question.instructions}>
{Object.entries(question.parts)
.filter(([p, _]) => hasPrefix(visibleSectionIDs, `${q}-${p}-`))
.map(([p_, part]) => {
const p = Number(p_)
return (
<Part
key={p}
partId={p}
description={part.instructions}
marksContribution={sum(map(part.sections, 'maximumMark'))}
>
{Object.entries(part.sections)
.filter(([s, _]) => hasPrefix(visibleSectionIDs, `${q}-${p}-${s}`))
.map(([s_, section], i) => {
const s = Number(s_)
const sectionId = `${q}-${p}-${s}`
const mark = lookupMark(student.username, q, p, s)
return (
<ReadOnlyTaskFactory
key={`${sectionId}-${t}`}
{...({
answer: answer,
...instanceToPlain(task),
} as TaskProps)}
/>
<Section
key={s}
sectionId={sectionId}
description={section.instructions}
>
{section.tasks.map((task, t_) => {
const t = t_ + 1
const answer = lookupAnswer(student.username, q, p, s, t)
return (
<ReadOnlyTaskFactory
key={`${sectionId}-${t}`}
{...({
answer: answer,
...instanceToPlain(task),
} as TaskProps)}
/>
)
})}

<MarkInputPanel
username={student.username}
question={q}
part={p}
section={s}
currentMark={mark}
maximumMark={section.maximumMark}
onSave={saveMark}
/>
{i + 1 !== Object.keys(part.sections).length && (
<Separator size="4" />
)}
</Section>
)
})}

<MarkInputPanel
username={student.username}
question={q}
part={p}
section={s}
currentMark={mark}
maximumMark={section.maximumMark}
onSave={saveMark}
/>
{i + 1 !== Object.keys(part.sections).length && <Separator size="4" />}
</Section>
)
})}
</Part>
)
})}
</Question>
</Flex>
</Box>
)
})}
</Part>
)
})}
</Question>
</Flex>
</Box>
)
})}
</Box>
)
}
Expand Down
Loading

0 comments on commit 1c6e590

Please sign in to comment.