Skip to content
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 @@ -293,18 +293,7 @@
}

.toolbar {
display: flex;
justify-content: space-between;
align-items: center;
gap: var(--rs-space-3);
margin-bottom: var(--rs-space-3);
flex-wrap: wrap;
}
.toolbarGroup {
display: flex;
gap: var(--rs-space-2);
align-items: center;
flex-wrap: wrap;
}
.releaseControl {
display: inline-flex;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ const SECTIONS: Array<{ title: string; rows: Row[] }> = [
rows: [
{ keys: ["G"], label: "Focus grade input" },
{ keys: ["C"], label: "Focus comment box" },
{ keys: ["H"], label: "Toggle 'hide graded' filter" }
{ keys: ["H"], label: "Toggle 'hide full credit' filter" }
]
},
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,11 @@
.controls > * {
width: 100%;
}
.filterOptions {
display: flex;
flex-direction: column;
gap: 0.55rem;
}

.list {
list-style: none;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,10 @@ const manualQuestion = { autograde: "manual" };

const renderSidebar = (props: Partial<React.ComponentProps<typeof StudentListSidebar>> = {}) => {
const onSelect = vi.fn();
const onToggleHideGraded = vi.fn();
const onToggleHideFullCredit = vi.fn();
const onToggleHideUnanswered = vi.fn();
const answers = props.answers ?? [
makeAnswer({ sid: "s1", first_name: "Ada", last_name: "Lovelace", score: 5, comment: "ok" }),
makeAnswer({ sid: "s1", first_name: "Ada", last_name: "Lovelace", score: 10, comment: "ok" }),
makeAnswer({ sid: "s2", first_name: "Bob", last_name: "Stone" }),
makeAnswer({ sid: "s3", attempts: 0, score: null })
];
Expand All @@ -32,12 +33,14 @@ const renderSidebar = (props: Partial<React.ComponentProps<typeof StudentListSid
answers={answers}
question={manualQuestion}
onSelect={onSelect}
hideGraded={false}
onToggleHideGraded={onToggleHideGraded}
hideFullCredit={false}
onToggleHideFullCredit={onToggleHideFullCredit}
hideUnanswered={false}
onToggleHideUnanswered={onToggleHideUnanswered}
{...props}
/>
);
return { onSelect, onToggleHideGraded };
return { onSelect, onToggleHideFullCredit, onToggleHideUnanswered };
};

describe("StudentListSidebar", () => {
Expand All @@ -59,7 +62,7 @@ describe("StudentListSidebar", () => {

it("filters the list by name and shows an empty note when nothing matches", async () => {
renderSidebar();
const filter = screen.getByPlaceholderText("Filter…");
const filter = screen.getByPlaceholderText("Search students…");

await userEvent.type(filter, "Ada");
expect(screen.getAllByRole("option")).toHaveLength(1);
Expand Down Expand Up @@ -87,22 +90,60 @@ describe("StudentListSidebar", () => {
expect(onSelect).toHaveBeenCalledWith("s2");
});

it("toggles the hide-graded control and reports the new value", async () => {
const { onToggleHideGraded } = renderSidebar();
const toggle = screen.getByRole("button", { name: "Hide already-graded students" });
it("reports changes to both student filters", async () => {
const { onToggleHideFullCredit, onToggleHideUnanswered } = renderSidebar();

expect(toggle).toHaveTextContent("All students");
await userEvent.click(toggle);
expect(onToggleHideGraded).toHaveBeenCalledWith(true);
await userEvent.click(screen.getByRole("button", { name: "Student filters, 0 active" }));
await userEvent.click(screen.getByRole("checkbox", { name: "Hide students with full credit" }));
await userEvent.click(
screen.getByRole("checkbox", { name: "Hide students with no submission" })
);

expect(onToggleHideFullCredit).toHaveBeenCalledWith(true);
expect(onToggleHideUnanswered).toHaveBeenCalledWith(true);
});

it("hides graded students and relabels the toggle when hideGraded is on", () => {
renderSidebar({ hideGraded: true });
it("shows the number of active filters on the trigger", () => {
renderSidebar({ hideFullCredit: true, hideUnanswered: true });

expect(screen.getByRole("button", { name: "Hide already-graded students" })).toHaveTextContent(
"Hide graded"
expect(screen.getByRole("button", { name: "Student filters, 2 active" })).toHaveTextContent(
"Filters (2)"
);
});

it("hides only students with full credit when that filter is on", () => {
renderSidebar({ hideFullCredit: true });

expect(screen.queryByText("Ada Lovelace")).not.toBeInTheDocument();
expect(screen.getByText("Bob Stone")).toBeInTheDocument();
expect(screen.getAllByRole("option")).toHaveLength(2);
});

it("keeps partially credited students visible when full-credit students are hidden", () => {
renderSidebar({
hideFullCredit: true,
answers: [
makeAnswer({ sid: "partial", first_name: "Partial", score: 5 }),
makeAnswer({ sid: "full", first_name: "Full", score: 10 })
]
});

expect(screen.getByText("Partial")).toBeInTheDocument();
expect(screen.queryByText("Full")).not.toBeInTheDocument();
});

it("hides students with no submission when that filter is on", () => {
renderSidebar({ hideUnanswered: true });

expect(screen.queryByText("s3")).not.toBeInTheDocument();
expect(screen.getAllByRole("option")).toHaveLength(2);
});

it("combines the full-credit and no-submission filters", () => {
renderSidebar({ hideFullCredit: true, hideUnanswered: true });

expect(screen.queryByText("Ada Lovelace")).not.toBeInTheDocument();
expect(screen.queryByText("s3")).not.toBeInTheDocument();
expect(screen.getByText("Bob Stone")).toBeInTheDocument();
});

Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Button, Progress, TextInput } from "@mantine/core";
import { Button, Checkbox, Popover, Progress, TextInput } from "@mantine/core";
import { GraderStudentAnswer, LateStudent } from "@store/grader/grader.logic.api";
import React, { useMemo, useRef } from "react";

Expand Down Expand Up @@ -28,8 +28,10 @@ interface Props {
courseTimezone?: string;
deadlineEnforced?: boolean;
onSelect: (sid: string) => void;
hideGraded: boolean;
onToggleHideGraded: (v: boolean) => void;
hideFullCredit: boolean;
onToggleHideFullCredit: (v: boolean) => void;
hideUnanswered: boolean;
onToggleHideUnanswered: (v: boolean) => void;
}

export const StudentListSidebar: React.FC<Props> = ({
Expand All @@ -42,26 +44,28 @@ export const StudentListSidebar: React.FC<Props> = ({
courseTimezone = "UTC",
deadlineEnforced,
onSelect,
hideGraded,
onToggleHideGraded
hideFullCredit,
onToggleHideFullCredit,
hideUnanswered,
onToggleHideUnanswered
}) => {
const [filter, setFilter] = React.useState("");
const progress = useMemo(
() => getQuestionProgress(answers, question, { dirtySids }),
[answers, question, dirtySids]
);
const activeFilterCount = Number(hideFullCredit) + Number(hideUnanswered);

const filtered = useMemo(() => {
const q = filter.trim().toLowerCase();

return answers.filter((a) => {
const status = getStudentStatus(a, question, { dirtySids });

if (hideGraded && (status === "graded" || status === "autograded")) return false;
if (hideFullCredit && a.score != null && a.score >= a.max_points) return false;
if (hideUnanswered && a.attempts === 0) return false;
if (!q) return true;
return studentDisplayName(a).toLowerCase().includes(q) || a.sid.toLowerCase().includes(q);
});
}, [answers, filter, hideGraded, question, dirtySids]);
}, [answers, filter, hideFullCredit, hideUnanswered]);

const listRef = useRef<HTMLUListElement | null>(null);

Expand Down Expand Up @@ -102,20 +106,41 @@ export const StudentListSidebar: React.FC<Props> = ({
<TextInput
value={filter}
onChange={(e) => setFilter(e.currentTarget.value)}
placeholder="Filter…"
placeholder="Search students…"
leftSection={<Icon name="search" size={14} />}
size="xs"
/>
<Button
variant={hideGraded ? "filled" : "default"}
size="xs"
fullWidth
leftSection={<Icon name={hideGraded ? "filter-fill" : "filter"} size={13} />}
onClick={() => onToggleHideGraded(!hideGraded)}
aria-label="Hide already-graded students"
>
{hideGraded ? "Hide graded" : "All students"}
</Button>
<Popover width="target" position="bottom-start" shadow="md">
<Popover.Target>
<Button
variant={activeFilterCount > 0 ? "light" : "default"}
size="xs"
fullWidth
leftSection={
<Icon name={activeFilterCount > 0 ? "filter-fill" : "filter"} size={13} />
}
aria-label={`Student filters, ${activeFilterCount} active`}
>
Filters{activeFilterCount > 0 ? ` (${activeFilterCount})` : ""}
</Button>
</Popover.Target>
<Popover.Dropdown>
<div className={styles.filterOptions}>
<Checkbox
checked={hideFullCredit}
onChange={(e) => onToggleHideFullCredit(e.currentTarget.checked)}
label="Hide students with full credit"
size="xs"
/>
<Checkbox
checked={hideUnanswered}
onChange={(e) => onToggleHideUnanswered(e.currentTarget.checked)}
label="Hide students with no submission"
size="xs"
/>
</div>
</Popover.Dropdown>
</Popover>
</div>
</header>

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -139,14 +139,14 @@ describe("useGraderHotkeys", () => {
expect(handlers.focusComment).toHaveBeenCalledOnce();
});

it("calls toggleHideGraded on h key", () => {
const handlers: GraderHotkeyHandlers = { toggleHideGraded: vi.fn() };
it("calls toggleHideFullCredit on h key", () => {
const handlers: GraderHotkeyHandlers = { toggleHideFullCredit: vi.fn() };

renderHook(() => useGraderHotkeys(handlers));

fireKeyDown("h");

expect(handlers.toggleHideGraded).toHaveBeenCalledOnce();
expect(handlers.toggleHideFullCredit).toHaveBeenCalledOnce();
});

it("calls openHelp on ? key", () => {
Expand Down Expand Up @@ -297,7 +297,7 @@ describe("useGraderHotkeys", () => {
expect(() => fireKeyDown("c")).not.toThrow();
});

it("does not throw when toggleHideGraded is not provided", () => {
it("does not throw when toggleHideFullCredit is not provided", () => {
renderHook(() => useGraderHotkeys({}));
expect(() => fireKeyDown("h")).not.toThrow();
});
Expand All @@ -312,7 +312,7 @@ describe("useGraderHotkeys", () => {
prevAttempt: vi.fn(),
focusGrade: vi.fn(),
focusComment: vi.fn(),
toggleHideGraded: vi.fn(),
toggleHideFullCredit: vi.fn(),
openHelp: vi.fn()
};

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ export interface GraderHotkeyHandlers {

focusComment?: () => void;

toggleHideGraded?: () => void;
toggleHideFullCredit?: () => void;

openHelp?: () => void;
}
Expand Down Expand Up @@ -88,7 +88,7 @@ export const useGraderHotkeys = (
e.preventDefault();
break;
case "h":
handlers.toggleHideGraded?.();
handlers.toggleHideFullCredit?.();
break;
}
};
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
export { Grader } from "./Grader";
export { default as GraderAssignmentsPage } from "./pages/GraderAssignmentsPage";
export { default as GraderQuestionsPage } from "./pages/GraderQuestionsPage";
export { default as GraderQuestionPage } from "./pages/GraderQuestionPage";
export { default as GraderGradebookPage } from "./pages/GraderGradebookPage";
export {
GraderAssignmentsPage,
GraderGradebookPage,
GraderQuestionPage,
GraderQuestionsPage
} from "./pages";
Original file line number Diff line number Diff line change
Expand Up @@ -8,16 +8,17 @@ import { useNavigate } from "react-router-dom";

import { DataGrid } from "@/components/ui/DataGrid";
import { Icon } from "@/components/ui/Icon";
import { Toolbar } from "@/components/ui/Toolbar/Toolbar";
import { usePersistedPagination } from "@/hooks/usePersistedPagination";
import { parseUTCDate } from "@/utils/date";

import styles from "../Grader.module.css";
import { ReleaseStatusBadge } from "../components/ReleaseStatusBadge";
import { GraderViewMode, ViewModeToggle } from "../components/ViewModeToggle";
import { useViewModeStorage } from "../hooks/useViewModeStorage";
import { effectiveViewMode } from "../state/graderSelectors";
import { useGraderTourContext } from "../tour/GraderTourContext";
import { DEMO_ASSIGNMENTS } from "../tour/graderDemoData";
import styles from "../../Grader.module.css";
import { ReleaseStatusBadge } from "../../components/ReleaseStatusBadge";
import { GraderViewMode, ViewModeToggle } from "../../components/ViewModeToggle";
import { useViewModeStorage } from "../../hooks/useViewModeStorage";
import { effectiveViewMode } from "../../state/graderSelectors";
import { useGraderTourContext } from "../../tour/GraderTourContext";
import { DEMO_ASSIGNMENTS } from "../../tour/graderDemoData";

const VIEW_MODES = ["cards", "table"] as const satisfies readonly GraderViewMode[];
const VIEW_MODE_STORAGE_KEY = "grader.assignmentsViewMode";
Expand Down Expand Up @@ -226,15 +227,18 @@ export const GraderAssignmentsPage: React.FC = () => {
: allRows;

const viewToggle = (
<div className={styles.toolbar}>
<ViewModeToggle
value={activeViewMode}
onChange={setViewMode}
ariaLabel="Toggle assignments view"
tourId="grader-assignments-view-toggle"
disabled={isDemo}
/>
</div>
<Toolbar
className={styles.toolbar}
end={
<ViewModeToggle
value={activeViewMode}
onChange={setViewMode}
ariaLabel="Toggle assignments view"
tourId="grader-assignments-view-toggle"
disabled={isDemo}
/>
}
/>
);

if (activeViewMode === "table") {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { GraderAssignmentsPage } from "./GraderAssignmentsPage";
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest";

import { fireEvent, renderWithMantine, screen } from "@/test/renderWithMantine";

import { GraderAssignmentsPage } from "./GraderAssignmentsPage";
import { GraderAssignmentsPage } from "../GraderAssignmentsPage";

const { mockUseGetAssignmentsQuery } = vi.hoisted(() => ({
mockUseGetAssignmentsQuery: vi.fn()
Expand Down
Loading
Loading