355 lines
12 KiB
TypeScript
355 lines
12 KiB
TypeScript
// Path: Frontend/src/components/assignment/teacher/assignment-teacher-review.tsx
|
|
|
|
import type { Component } from "solid-js";
|
|
import { createEffect, createMemo, createSignal, For, Show } from "solid-js";
|
|
import { useNavigate } from "@solidjs/router";
|
|
import type { TeacherAssignmentReviewPageData, TeacherReviewDraftFields, TeacherReviewNotice, TeacherReviewQuestion } from "./assignment-teacher-review.types";
|
|
import { closeTeacherAssignment, updateTeacherAnswerReview } from "./assignment-teacher-review.data";
|
|
import {
|
|
applyReviewDrafts,
|
|
type AssignmentReviewDraftStore,
|
|
countPendingQuestionDrafts,
|
|
EMPTY_DRAFT_STORE,
|
|
type StudentReviewDraft,
|
|
readDraftStore,
|
|
reviewFieldsEqual,
|
|
reviewFieldsFromQuestion,
|
|
sanitizeReviewFields,
|
|
writeDraftStore,
|
|
} from "./assignment-teacher-review.drafts";
|
|
import styles from "./assignment-teacher-review.module.scss";
|
|
import { TeacherReviewQuestionCard, TeacherReviewSaveProgressCard, TeacherReviewSidebar } from "./assignment-teacher-review.sections";
|
|
import { getTeacherAssignmentNextStepHref } from "../../../lib/routes";
|
|
|
|
type Props = {
|
|
data: TeacherAssignmentReviewPageData;
|
|
onSelectStudent: (studentId: number) => void;
|
|
onRefresh: () => Promise<unknown> | unknown;
|
|
};
|
|
|
|
const AssignmentTeacherReview: Component<Props> = (props) => {
|
|
const navigate = useNavigate();
|
|
let questionReviewSectionRef: HTMLElement | undefined;
|
|
const [draftStore, setDraftStore] = createSignal<AssignmentReviewDraftStore>(EMPTY_DRAFT_STORE);
|
|
const [savingAll, setSavingAll] = createSignal(false);
|
|
const [closingAssignment, setClosingAssignment] = createSignal(false);
|
|
const [notice, setNotice] = createSignal<TeacherReviewNotice>(null);
|
|
const [closeNotice, setCloseNotice] = createSignal<TeacherReviewNotice>(null);
|
|
const [questionNavExpanded, setQuestionNavExpanded] = createSignal(false);
|
|
|
|
const currentStudentKey = createMemo(() => (props.data.selectedStudentId ? String(props.data.selectedStudentId) : null));
|
|
const currentStudentDraft = createMemo(() => {
|
|
const studentKey = currentStudentKey();
|
|
if (!studentKey) return null;
|
|
return draftStore().students[studentKey] ?? null;
|
|
});
|
|
const displayQuestions = createMemo(() => applyReviewDrafts(props.data.questions, currentStudentDraft()));
|
|
const pendingChangeCount = createMemo(() => countPendingQuestionDrafts(draftStore()));
|
|
createEffect(() => {
|
|
setDraftStore(readDraftStore(props.data.assignmentId));
|
|
});
|
|
|
|
createEffect(() => {
|
|
writeDraftStore(props.data.assignmentId, draftStore());
|
|
});
|
|
|
|
createEffect(() => {
|
|
setNotice(null);
|
|
});
|
|
|
|
createEffect(() => {
|
|
setCloseNotice(null);
|
|
});
|
|
|
|
const hasQueue = createMemo(() => props.data.reviewQueue.length > 0);
|
|
const canMoveToNextStep = createMemo(() => {
|
|
return props.data.selectedStudentSubmittedQuestions > 0 || props.data.selectedStudentReviewedQuestions > 0;
|
|
});
|
|
const getBaseQuestion = (questionId: number) => props.data.questions.find((entry) => entry.id === questionId);
|
|
const getSelectedStudentQuestions = () => props.data.questions.filter((question) => question.answerId);
|
|
const getQuestionCardId = (questionId: number) => `teacher-review-question-${questionId}`;
|
|
const handleSelectQuestion = (questionId: number) => {
|
|
const element = document.getElementById(getQuestionCardId(questionId));
|
|
element?.scrollIntoView({ behavior: "smooth", block: "start" });
|
|
setQuestionNavExpanded(false);
|
|
};
|
|
const handleScrollToTop = () => {
|
|
questionReviewSectionRef?.scrollIntoView({ behavior: "smooth", block: "start" });
|
|
};
|
|
|
|
const updateStudentDraft = (studentId: number, updater: (draft: StudentReviewDraft) => StudentReviewDraft | null) => {
|
|
const studentKey = String(studentId);
|
|
setDraftStore((current) => {
|
|
const nextStudents = { ...current.students };
|
|
const nextDraft = updater(nextStudents[studentKey] ?? {});
|
|
if (!nextDraft) {
|
|
delete nextStudents[studentKey];
|
|
} else {
|
|
nextStudents[studentKey] = nextDraft;
|
|
}
|
|
|
|
return {
|
|
students: nextStudents,
|
|
};
|
|
});
|
|
};
|
|
|
|
const updateQuestionReviewDraft = (question: TeacherReviewQuestion, patch: Partial<TeacherReviewDraftFields>) => {
|
|
if (!question.answerId || !props.data.selectedStudentId) return;
|
|
setNotice(null);
|
|
const baseQuestion = getBaseQuestion(question.id) ?? question;
|
|
const baseFields = sanitizeReviewFields(reviewFieldsFromQuestion(baseQuestion));
|
|
|
|
updateStudentDraft(props.data.selectedStudentId, (draft) => {
|
|
const questionReviews = { ...(draft.questionReviews ?? {}) };
|
|
const currentFields = sanitizeReviewFields(
|
|
questionReviews[String(question.id)]
|
|
? {
|
|
...baseFields,
|
|
...questionReviews[String(question.id)],
|
|
}
|
|
: baseFields
|
|
);
|
|
const nextFields = sanitizeReviewFields({
|
|
...currentFields,
|
|
...patch,
|
|
});
|
|
|
|
if (reviewFieldsEqual(nextFields, baseFields)) {
|
|
delete questionReviews[String(question.id)];
|
|
} else {
|
|
questionReviews[String(question.id)] = {
|
|
answerId: question.answerId,
|
|
...nextFields,
|
|
};
|
|
}
|
|
|
|
const nextDraft: StudentReviewDraft = {
|
|
...draft,
|
|
};
|
|
if (Object.keys(questionReviews).length > 0) {
|
|
nextDraft.questionReviews = questionReviews;
|
|
} else {
|
|
delete nextDraft.questionReviews;
|
|
}
|
|
|
|
if (Object.keys(nextDraft.questionReviews ?? {}).length === 0) {
|
|
return null;
|
|
}
|
|
|
|
return nextDraft;
|
|
});
|
|
};
|
|
|
|
const resetQuestionDraft = (question: TeacherReviewQuestion) => {
|
|
const selectedStudentId = props.data.selectedStudentId;
|
|
if (!selectedStudentId) return;
|
|
setNotice(null);
|
|
updateStudentDraft(selectedStudentId, (draft) => {
|
|
const questionReviews = { ...(draft.questionReviews ?? {}) };
|
|
delete questionReviews[String(question.id)];
|
|
|
|
if (Object.keys(questionReviews).length === 0) {
|
|
return null;
|
|
}
|
|
|
|
return {
|
|
...draft,
|
|
questionReviews,
|
|
};
|
|
});
|
|
};
|
|
const markQuestionNeedsAttention = (question: TeacherReviewQuestion) => {
|
|
updateQuestionReviewDraft(question, {
|
|
reviewNeedsAttention: true,
|
|
});
|
|
};
|
|
|
|
const saveAllAndNextStep = async () => {
|
|
setNotice(null);
|
|
const selectedStudentId = props.data.selectedStudentId;
|
|
if (!selectedStudentId) return;
|
|
if (!canMoveToNextStep()) {
|
|
setNotice({
|
|
scope: "global",
|
|
tone: "error",
|
|
text: "Students need a submitted review before you can move them to the next step.",
|
|
});
|
|
return;
|
|
}
|
|
|
|
const operations: Array<() => Promise<unknown>> = [];
|
|
const selectedStudentDraft = draftStore().students[String(selectedStudentId)] ?? null;
|
|
const questionDrafts = selectedStudentDraft?.questionReviews ?? {};
|
|
|
|
for (const question of getSelectedStudentQuestions()) {
|
|
const draft = questionDrafts[String(question.id)];
|
|
if (!question.answerId) continue;
|
|
const baseFields = sanitizeReviewFields(reviewFieldsFromQuestion(question));
|
|
const effectiveReview = sanitizeReviewFields(
|
|
draft
|
|
? {
|
|
...baseFields,
|
|
...draft,
|
|
}
|
|
: baseFields
|
|
);
|
|
const nextStatus = effectiveReview.reviewNeedsAttention ? "submitted" : "reviewed";
|
|
const hasReviewFieldChanges = !reviewFieldsEqual(effectiveReview, baseFields);
|
|
if (question.answerStatus === nextStatus && !hasReviewFieldChanges) continue;
|
|
|
|
operations.push(() =>
|
|
updateTeacherAnswerReview(question.answerId!, {
|
|
status: nextStatus,
|
|
reviewTags: [...question.questionTags],
|
|
...effectiveReview,
|
|
})
|
|
);
|
|
}
|
|
|
|
setSavingAll(true);
|
|
try {
|
|
for (const operation of operations) {
|
|
await operation();
|
|
}
|
|
updateStudentDraft(selectedStudentId, () => null);
|
|
navigate(getTeacherAssignmentNextStepHref(props.data.assignmentId, selectedStudentId));
|
|
} catch (error) {
|
|
setNotice({
|
|
scope: "global",
|
|
tone: "error",
|
|
text: error instanceof Error ? error.message : "Could not save all review changes right now.",
|
|
});
|
|
} finally {
|
|
setSavingAll(false);
|
|
}
|
|
};
|
|
|
|
const handleCloseAssignment = async () => {
|
|
setCloseNotice(null);
|
|
if (!props.data.closeSummary.canClose) {
|
|
setCloseNotice({
|
|
scope: "assignment",
|
|
tone: "error",
|
|
text: props.data.closeSummary.summary,
|
|
});
|
|
return;
|
|
}
|
|
|
|
setClosingAssignment(true);
|
|
try {
|
|
await closeTeacherAssignment(props.data.assignmentId);
|
|
await props.onRefresh();
|
|
setCloseNotice({
|
|
scope: "assignment",
|
|
tone: "success",
|
|
text: "Assignment closed.",
|
|
});
|
|
} catch (error) {
|
|
setCloseNotice({
|
|
scope: "assignment",
|
|
tone: "error",
|
|
text: error instanceof Error ? error.message : "Could not close this assignment right now.",
|
|
});
|
|
} finally {
|
|
setClosingAssignment(false);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<>
|
|
<div class={styles.contentGrid}>
|
|
<div class={styles.mainColumn}>
|
|
<section ref={questionReviewSectionRef} class={styles.section}>
|
|
<div class={styles.sectionHeader}>
|
|
<h2>Question review</h2>
|
|
<p>
|
|
<Show when={props.data.selectedStudentName} fallback={hasQueue() ? "Choose a student to begin reviewing." : "This assignment has no student review queue yet."}>
|
|
{props.data.selectedStudentName} · {props.data.questions.length} questions
|
|
</Show>
|
|
</p>
|
|
</div>
|
|
|
|
<Show when={props.data.selectedStudentId !== null && displayQuestions().length > 0}>
|
|
<section class={styles.mobileStickyNav}>
|
|
<div class={styles.mobileStickyNavHeader}>
|
|
<div>
|
|
<h3>Questions</h3>
|
|
<p>{displayQuestions().length} questions in this review</p>
|
|
</div>
|
|
<div class={styles.mobileStickyNavActions}>
|
|
<button type="button" class={styles.mobileStickyNavBackToTop} onClick={handleScrollToTop}>
|
|
Back to top
|
|
</button>
|
|
<button
|
|
type="button"
|
|
class={styles.mobileStickyNavToggle}
|
|
aria-expanded={questionNavExpanded()}
|
|
onClick={() => setQuestionNavExpanded((value) => !value)}
|
|
>
|
|
{questionNavExpanded() ? "Hide list" : "Open list"}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<div
|
|
classList={{
|
|
[styles.mobileStickyNavContent]: true,
|
|
[styles.mobileStickyNavContentExpanded]: questionNavExpanded(),
|
|
}}
|
|
>
|
|
<div class={styles.mobileQuestionNavList}>
|
|
<For each={displayQuestions()}>
|
|
{(question) => (
|
|
<button
|
|
type="button"
|
|
class={`${styles.mobileQuestionNavButton} ${question.answerId ? styles.mobileQuestionNavAnswered : ""}`.trim()}
|
|
onClick={() => handleSelectQuestion(question.id)}
|
|
>
|
|
<strong>Q{question.order}</strong>
|
|
<small>{question.statusLabel}</small>
|
|
</button>
|
|
)}
|
|
</For>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
</Show>
|
|
|
|
<Show when={props.data.selectedStudentId !== null} fallback={<div class={styles.emptyState}>Select a student from the review queue to inspect answers and leave feedback.</div>}>
|
|
<div class={styles.questionList}>
|
|
<For each={displayQuestions()}>
|
|
{(question) => (
|
|
<TeacherReviewQuestionCard
|
|
cardId={getQuestionCardId(question.id)}
|
|
question={question}
|
|
notice={notice()}
|
|
hasPendingDraft={Boolean(currentStudentDraft()?.questionReviews?.[String(question.id)])}
|
|
savingAll={savingAll()}
|
|
onResetDraft={resetQuestionDraft}
|
|
onNeedsAttention={markQuestionNeedsAttention}
|
|
onReviewFieldChange={updateQuestionReviewDraft}
|
|
/>
|
|
)}
|
|
</For>
|
|
</div>
|
|
|
|
<TeacherReviewSaveProgressCard
|
|
canMoveToNextStep={canMoveToNextStep()}
|
|
pendingChangeCount={pendingChangeCount()}
|
|
savingAll={savingAll()}
|
|
closeState={props.data.closeSummary.state}
|
|
notice={notice()}
|
|
onSave={saveAllAndNextStep}
|
|
/>
|
|
</Show>
|
|
</section>
|
|
</div>
|
|
|
|
<TeacherReviewSidebar data={props.data} hasQueue={hasQueue()} closingAssignment={closingAssignment()} closeNotice={closeNotice()} onSelectStudent={props.onSelectStudent} onCloseAssignment={handleCloseAssignment} />
|
|
</div>
|
|
</>
|
|
);
|
|
};
|
|
|
|
export default AssignmentTeacherReview;
|