From 1c6e59049211a41bceb45105c424ae8954a4e7a9 Mon Sep 17 00:00:00 2001 From: Ivan Procaccini Date: Tue, 8 Oct 2024 13:41:26 +0200 Subject: [PATCH] feat: implement horizontal marking (#36) * 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 --- src/hooks/marking.ts | 13 +- src/index.css | 15 +++ .../MarkingPage/HorizontalMarkingPane.tsx | 114 ++++++++++++++++ src/pages/MarkingPage/MarkableSubmission.tsx | 127 ++++++++++-------- src/pages/MarkingPage/Scrollspy/index.tsx | 80 ++++++----- src/pages/MarkingPage/index.tsx | 16 ++- src/utils/common.ts | 4 + 7 files changed, 270 insertions(+), 99 deletions(-) create mode 100644 src/pages/MarkingPage/HorizontalMarkingPane.tsx diff --git a/src/hooks/marking.ts b/src/hooks/marking.ts index 4a39b69..dd951ba 100644 --- a/src/hooks/marking.ts +++ b/src/hooks/marking.ts @@ -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' @@ -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 = () => { diff --git a/src/index.css b/src/index.css index cef518e..dbc3466 100644 --- a/src/index.css +++ b/src/index.css @@ -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; diff --git a/src/pages/MarkingPage/HorizontalMarkingPane.tsx b/src/pages/MarkingPage/HorizontalMarkingPane.tsx new file mode 100644 index 0000000..f46e1f5 --- /dev/null +++ b/src/pages/MarkingPage/HorizontalMarkingPane.tsx @@ -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 + sectionIDs: string[] + onActiveSectionsUpdate: (sectionIDs: string[]) => void +} + +const HorizontalMarkingPane: FC = ({ + questions, + sectionIDs, + onActiveSectionsUpdate, +}) => { + const [horizontalMarkingState, setHorizontalMarkingState] = useState>( + 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 ( + + + + + + + {Object.entries(questions).map(([q, question]) => ( + + handleBulkToggleByPrefix(q)}> + Question {q} + + + {Object.entries(question.parts).map(([p, part]) => { + const partID = `${q}-${p}` + return ( + + handleBulkToggleByPrefix(partID)} + > + Part {numberToLetter(Number(p))} + + + handleSectionSelectionToggle(partID, vs)} + > + {Object.keys(part.sections).map((s) => { + const sectionID = `${q}-${p}-${s}` + return ( + + Section {numberToRoman(Number(s))} + + ) + })} + + + + ) + })} + + ))} + + + + + + + + ) +} + +export default HorizontalMarkingPane diff --git a/src/pages/MarkingPage/MarkableSubmission.tsx b/src/pages/MarkingPage/MarkableSubmission.tsx index 8d82f3b..15db03c 100644 --- a/src/pages/MarkingPage/MarkableSubmission.tsx +++ b/src/pages/MarkingPage/MarkableSubmission.tsx @@ -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 + visibleSectionIDs: string[] + student: Student lookupMark: (student: string, question: number, part: number, section: number) => MarkRoot lookupAnswer: ( student: string, @@ -27,71 +29,84 @@ interface MarkableSubmissionProps { } const MarkableSubmission: FC = ({ - student, questions, + visibleSectionIDs, + student, lookupMark, lookupAnswer, saveMark, }) => { return ( - {Object.entries(questions).map(([q_, question]) => { - const q = Number(q_) - return ( - - - - - {Object.entries(question.parts).map(([p_, part]) => { - const p = Number(p_) - return ( - - {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.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 ( + + + + + {Object.entries(question.parts) + .filter(([p, _]) => hasPrefix(visibleSectionIDs, `${q}-${p}-`)) + .map(([p_, part]) => { + const p = Number(p_) + return ( + + {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 ( - +
+ {section.tasks.map((task, t_) => { + const t = t_ + 1 + const answer = lookupAnswer(student.username, q, p, s, t) + return ( + + ) + })} + + + {i + 1 !== Object.keys(part.sections).length && ( + + )} +
) })} - - - {i + 1 !== Object.keys(part.sections).length && } -
- ) - })} -
- ) - })} -
-
-
- ) - })} + + ) + })} + + +
+ ) + })} ) } diff --git a/src/pages/MarkingPage/Scrollspy/index.tsx b/src/pages/MarkingPage/Scrollspy/index.tsx index d0b8d71..d4d0529 100644 --- a/src/pages/MarkingPage/Scrollspy/index.tsx +++ b/src/pages/MarkingPage/Scrollspy/index.tsx @@ -4,16 +4,17 @@ import React, { FC } from 'react' import { Question as QuestionType } from '../../../types/exam' import { MarkRoot } from '../../../types/marking' -import { numberToLetter, numberToRoman } from '../../../utils/common' +import { hasPrefix, numberToLetter, numberToRoman } from '../../../utils/common' import { ScrollspyItem } from './ScrollspyItem' interface ScrollspyProps { questions: Record marks: MarkRoot[] activeId: string | undefined + visibleSectionIDs: string[] } -const Scrollspy: FC = ({ questions, marks, activeId }) => { +const Scrollspy: FC = ({ questions, marks, activeId, visibleSectionIDs }) => { function currentQuestionMark(q: number): number | undefined { let relevantMarks = marks.filter((m) => m.question === q && !isNil(m.mark)) return isEmpty(relevantMarks) ? undefined : sumBy(relevantMarks, 'mark') @@ -30,42 +31,47 @@ const Scrollspy: FC = ({ questions, marks, activeId }) => { return ( - {Object.entries(questions).map(([q_, question]) => { - const q = Number(q_) - const partial = currentQuestionMark(q) - const total = question.availableMarks - const color = pendingMarking(question, q) ? 'gray' : isNil(partial) ? 'red' : 'green' - return ( - - - - {Object.entries(question.parts).map(([p_, part]) => { - const p = Number(p_) - return Object.entries(part.sections).map(([s_, section]) => { - const s = Number(s_) - const partial = currentSectionMark(q, p, s) - const total = section.maximumMark - const color = isNil(partial) ? 'red' : 'green' - return ( - - ) - }) - })} + {Object.entries(questions) + .filter(([q, _]) => hasPrefix(visibleSectionIDs, `${q}-`)) + .map(([q_, question]) => { + const q = Number(q_) + const partial = currentQuestionMark(q) + const total = question.availableMarks + const color = pendingMarking(question, q) ? 'gray' : isNil(partial) ? 'red' : 'green' + return ( + + + + {Object.entries(question.parts).map(([p_, part]) => { + const p = Number(p_) + return Object.entries(part.sections) + .filter(([s, _]) => hasPrefix(visibleSectionIDs, `${q}-${p}-${s}`)) + .map(([s_, section]) => { + const s = Number(s_) + const partial = currentSectionMark(q, p, s) + const total = section.maximumMark + const color = isNil(partial) ? 'red' : 'green' + return ( + + ) + }) + })} + - - ) - })} + ) + })} ) } diff --git a/src/pages/MarkingPage/index.tsx b/src/pages/MarkingPage/index.tsx index 84eec8f..fe9234a 100644 --- a/src/pages/MarkingPage/index.tsx +++ b/src/pages/MarkingPage/index.tsx @@ -8,17 +8,19 @@ import MarkingToolbar from '../../components/topBars/MarkingToolbar' import useActiveIdOnScroll from '../../hooks/interactiveScrollspy' import { useAnswers, useMarks, useQuestions, useStudents } from '../../hooks/marking' import { Student } from '../../types/marking' +import HorizontalMarkingPane from './HorizontalMarkingPane' import MarkableSubmission from './MarkableSubmission' import Scrollspy from './Scrollspy' import './index.css' const MarkingPage: FC = () => { - const { questions, questionsAreLoaded } = useQuestions() + const { questions, allSectionIDs, questionsAreLoaded } = useQuestions() const { students, studentsAreLoaded } = useStudents() const { lookupMark, rawMarksTable, saveMark, marksAreLoaded } = useMarks() const { lookupAnswer, answersAreLoaded } = useAnswers() const [student, setStudent] = useState() - const activeId = useActiveIdOnScroll(['q1-1-1', 'q1-1-2', 'q1-2-1', 'q2-1-1', 'q2-1-2']) + const activeId = useActiveIdOnScroll(allSectionIDs) + const [visibleSectionIDs, setVisibleSectionIDs] = useState(allSectionIDs) const markingStatus = useMemo(() => { const sectionsToMark = sumBy(values(questions), 'totalSections') @@ -53,15 +55,20 @@ const MarkingPage: FC = () => { - Left + {!student ? ( ) : ( { diff --git a/src/utils/common.ts b/src/utils/common.ts index 5b7f51e..fd01bc9 100644 --- a/src/utils/common.ts +++ b/src/utils/common.ts @@ -34,3 +34,7 @@ export function numberToRoman(num: number): string { return result + numeral.repeat(repeatCount) }, '') } + +export function hasPrefix(collection: string[], prefix: string): boolean { + return collection.some((s) => s.startsWith(prefix)) +}