From d366816ccb1743d5ec64817083d305a8fe3fa4be Mon Sep 17 00:00:00 2001 From: Andrey Morozov Date: Tue, 6 Oct 2026 20:21:19 +0200 Subject: [PATCH 1/2] f-1569 add manual grading filter --- .../Grader/components/ShortcutsHelpDialog.tsx | 2 +- .../components/StudentListSidebar.module.css | 5 ++ .../components/StudentListSidebar.spec.tsx | 73 +++++++++++++++---- .../Grader/components/StudentListSidebar.tsx | 65 ++++++++++++----- .../Grader/hooks/useGraderHotkeys.spec.ts | 10 +-- .../routes/Grader/hooks/useGraderHotkeys.ts | 4 +- .../src/components/routes/Grader/index.ts | 10 ++- .../GraderAssignmentsPage.tsx | 14 ++-- .../pages/GraderAssignmentsPage/index.ts | 1 + .../spec/GraderAssignmentsPage.test.tsx} | 2 +- .../GraderGradebookPage.tsx | 12 +-- .../Grader/pages/GraderGradebookPage/index.ts | 1 + .../spec/GraderGradebookPage.test.tsx} | 8 +- .../GraderQuestionPage.tsx | 41 ++++++----- .../Grader/pages/GraderQuestionPage/index.ts | 1 + .../GraderQuestionsPage.tsx | 22 +++--- .../Grader/pages/GraderQuestionsPage/index.ts | 1 + .../spec/GraderQuestionsPage.test.tsx} | 14 ++-- .../components/routes/Grader/pages/index.ts | 4 + .../routes/Grader/tour/graderTourConfig.ts | 2 +- 20 files changed, 189 insertions(+), 103 deletions(-) rename bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/pages/{ => GraderAssignmentsPage}/GraderAssignmentsPage.tsx (95%) create mode 100644 bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/pages/GraderAssignmentsPage/index.ts rename bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/pages/{GraderAssignmentsPage.spec.tsx => GraderAssignmentsPage/spec/GraderAssignmentsPage.test.tsx} (97%) rename bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/pages/{ => GraderGradebookPage}/GraderGradebookPage.tsx (97%) create mode 100644 bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/pages/GraderGradebookPage/index.ts rename bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/pages/{GraderGradebookPage.spec.tsx => GraderGradebookPage/spec/GraderGradebookPage.test.tsx} (98%) rename bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/pages/{ => GraderQuestionPage}/GraderQuestionPage.tsx (91%) create mode 100644 bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/pages/GraderQuestionPage/index.ts rename bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/pages/{ => GraderQuestionsPage}/GraderQuestionsPage.tsx (95%) create mode 100644 bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/pages/GraderQuestionsPage/index.ts rename bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/pages/{GraderQuestionsPage.spec.tsx => GraderQuestionsPage/spec/GraderQuestionsPage.test.tsx} (87%) create mode 100644 bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/pages/index.ts diff --git a/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/components/ShortcutsHelpDialog.tsx b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/components/ShortcutsHelpDialog.tsx index 40936a4a5..89f1af467 100644 --- a/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/components/ShortcutsHelpDialog.tsx +++ b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/components/ShortcutsHelpDialog.tsx @@ -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" } ] }, { diff --git a/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/components/StudentListSidebar.module.css b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/components/StudentListSidebar.module.css index bb337fd3b..7d4d57e89 100644 --- a/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/components/StudentListSidebar.module.css +++ b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/components/StudentListSidebar.module.css @@ -86,6 +86,11 @@ .controls > * { width: 100%; } +.filterOptions { + display: flex; + flex-direction: column; + gap: 0.55rem; +} .list { list-style: none; diff --git a/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/components/StudentListSidebar.spec.tsx b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/components/StudentListSidebar.spec.tsx index 1b07118aa..61821df84 100644 --- a/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/components/StudentListSidebar.spec.tsx +++ b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/components/StudentListSidebar.spec.tsx @@ -20,9 +20,10 @@ const manualQuestion = { autograde: "manual" }; const renderSidebar = (props: Partial> = {}) => { 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 }) ]; @@ -32,12 +33,14 @@ const renderSidebar = (props: Partial ); - return { onSelect, onToggleHideGraded }; + return { onSelect, onToggleHideFullCredit, onToggleHideUnanswered }; }; describe("StudentListSidebar", () => { @@ -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); @@ -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(); }); diff --git a/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/components/StudentListSidebar.tsx b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/components/StudentListSidebar.tsx index 0d6f451c7..5168f315f 100644 --- a/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/components/StudentListSidebar.tsx +++ b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/components/StudentListSidebar.tsx @@ -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"; @@ -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 = ({ @@ -42,26 +44,28 @@ export const StudentListSidebar: React.FC = ({ 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(null); @@ -102,20 +106,41 @@ export const StudentListSidebar: React.FC = ({ setFilter(e.currentTarget.value)} - placeholder="Filter…" + placeholder="Search students…" leftSection={} size="xs" /> - + + + + + +
+ onToggleHideFullCredit(e.currentTarget.checked)} + label="Hide students with full credit" + size="xs" + /> + onToggleHideUnanswered(e.currentTarget.checked)} + label="Hide students with no submission" + size="xs" + /> +
+
+
diff --git a/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/hooks/useGraderHotkeys.spec.ts b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/hooks/useGraderHotkeys.spec.ts index 4f8036997..786733829 100644 --- a/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/hooks/useGraderHotkeys.spec.ts +++ b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/hooks/useGraderHotkeys.spec.ts @@ -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", () => { @@ -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(); }); @@ -312,7 +312,7 @@ describe("useGraderHotkeys", () => { prevAttempt: vi.fn(), focusGrade: vi.fn(), focusComment: vi.fn(), - toggleHideGraded: vi.fn(), + toggleHideFullCredit: vi.fn(), openHelp: vi.fn() }; diff --git a/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/hooks/useGraderHotkeys.ts b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/hooks/useGraderHotkeys.ts index 8650a5da1..4b06b7088 100644 --- a/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/hooks/useGraderHotkeys.ts +++ b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/hooks/useGraderHotkeys.ts @@ -15,7 +15,7 @@ export interface GraderHotkeyHandlers { focusComment?: () => void; - toggleHideGraded?: () => void; + toggleHideFullCredit?: () => void; openHelp?: () => void; } @@ -88,7 +88,7 @@ export const useGraderHotkeys = ( e.preventDefault(); break; case "h": - handlers.toggleHideGraded?.(); + handlers.toggleHideFullCredit?.(); break; } }; diff --git a/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/index.ts b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/index.ts index 0b730262b..1c23b78e3 100644 --- a/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/index.ts +++ b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/index.ts @@ -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"; diff --git a/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/pages/GraderAssignmentsPage.tsx b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/pages/GraderAssignmentsPage/GraderAssignmentsPage.tsx similarity index 95% rename from bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/pages/GraderAssignmentsPage.tsx rename to bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/pages/GraderAssignmentsPage/GraderAssignmentsPage.tsx index 59f0cc60c..2997b6dcc 100644 --- a/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/pages/GraderAssignmentsPage.tsx +++ b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/pages/GraderAssignmentsPage/GraderAssignmentsPage.tsx @@ -11,13 +11,13 @@ import { Icon } from "@/components/ui/Icon"; 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"; diff --git a/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/pages/GraderAssignmentsPage/index.ts b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/pages/GraderAssignmentsPage/index.ts new file mode 100644 index 000000000..77e4cae9e --- /dev/null +++ b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/pages/GraderAssignmentsPage/index.ts @@ -0,0 +1 @@ +export { GraderAssignmentsPage } from "./GraderAssignmentsPage"; diff --git a/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/pages/GraderAssignmentsPage.spec.tsx b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/pages/GraderAssignmentsPage/spec/GraderAssignmentsPage.test.tsx similarity index 97% rename from bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/pages/GraderAssignmentsPage.spec.tsx rename to bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/pages/GraderAssignmentsPage/spec/GraderAssignmentsPage.test.tsx index d848359ae..30de98b7e 100644 --- a/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/pages/GraderAssignmentsPage.spec.tsx +++ b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/pages/GraderAssignmentsPage/spec/GraderAssignmentsPage.test.tsx @@ -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() diff --git a/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/pages/GraderGradebookPage.tsx b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/pages/GraderGradebookPage/GraderGradebookPage.tsx similarity index 97% rename from bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/pages/GraderGradebookPage.tsx rename to bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/pages/GraderGradebookPage/GraderGradebookPage.tsx index 0b2d7835e..a54388cfc 100644 --- a/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/pages/GraderGradebookPage.tsx +++ b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/pages/GraderGradebookPage/GraderGradebookPage.tsx @@ -15,11 +15,11 @@ import React, { useMemo, useState } from "react"; import { Icon } from "@/components/ui/Icon"; -import { ErrorState } from "../../AssignmentBuilder/components/ErrorState/ErrorState"; -import styles from "../Grader.module.css"; -import { GradebookCellDialog } from "../components/GradebookCellDialog"; -import { GradebookLateWorkDialog } from "../components/GradebookLateWorkDialog"; -import { GradebookUnitsToggle } from "../components/GradebookUnitsToggle"; +import { ErrorState } from "../../../AssignmentBuilder/components/ErrorState/ErrorState"; +import styles from "../../Grader.module.css"; +import { GradebookCellDialog } from "../../components/GradebookCellDialog"; +import { GradebookLateWorkDialog } from "../../components/GradebookLateWorkDialog"; +import { GradebookUnitsToggle } from "../../components/GradebookUnitsToggle"; import { assignmentAverage, buildCellLookup, @@ -32,7 +32,7 @@ import { getCell, gradebookToCsv, studentTotalDisplay -} from "../state/gradebookSelectors"; +} from "../../state/gradebookSelectors"; interface OpenCell { assignment: GradebookAssignment; diff --git a/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/pages/GraderGradebookPage/index.ts b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/pages/GraderGradebookPage/index.ts new file mode 100644 index 000000000..251173883 --- /dev/null +++ b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/pages/GraderGradebookPage/index.ts @@ -0,0 +1 @@ +export { GraderGradebookPage } from "./GraderGradebookPage"; diff --git a/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/pages/GraderGradebookPage.spec.tsx b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/pages/GraderGradebookPage/spec/GraderGradebookPage.test.tsx similarity index 98% rename from bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/pages/GraderGradebookPage.spec.tsx rename to bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/pages/GraderGradebookPage/spec/GraderGradebookPage.test.tsx index 6b301d2f3..5e10560e1 100644 --- a/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/pages/GraderGradebookPage.spec.tsx +++ b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/pages/GraderGradebookPage/spec/GraderGradebookPage.test.tsx @@ -4,7 +4,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { renderWithMantine, screen, within } from "@/test/renderWithMantine"; -import { GraderGradebookPage } from "./GraderGradebookPage"; +import { GraderGradebookPage } from "../GraderGradebookPage"; const { mockUseGetGradebookQuery, mockCellDialog, mockLateDialog, mockUnitsToggle, mockRefetch } = vi.hoisted(() => ({ @@ -25,14 +25,14 @@ vi.mock("@store/grader/grader.logic.api", async (importOriginal) => { }); // The dialog has its own spec; here we only care about what the page hands it. -vi.mock("../components/GradebookCellDialog", () => ({ +vi.mock("../../../components/GradebookCellDialog", () => ({ GradebookCellDialog: (props: Record) => { mockCellDialog(props); return null; } })); -vi.mock("../components/GradebookLateWorkDialog", () => ({ +vi.mock("../../../components/GradebookLateWorkDialog", () => ({ GradebookLateWorkDialog: (props: Record) => { mockLateDialog(props); return null; @@ -41,7 +41,7 @@ vi.mock("../components/GradebookLateWorkDialog", () => ({ // Likewise the units toggle: it owns the mutation, so the page only has to tell // it which units the course is on. -vi.mock("../components/GradebookUnitsToggle", () => ({ +vi.mock("../../../components/GradebookUnitsToggle", () => ({ GradebookUnitsToggle: (props: Record) => { mockUnitsToggle(props); return null; diff --git a/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/pages/GraderQuestionPage.tsx b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/pages/GraderQuestionPage/GraderQuestionPage.tsx similarity index 91% rename from bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/pages/GraderQuestionPage.tsx rename to bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/pages/GraderQuestionPage/GraderQuestionPage.tsx index 27b5944f0..6914ba97f 100644 --- a/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/pages/GraderQuestionPage.tsx +++ b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/pages/GraderQuestionPage/GraderQuestionPage.tsx @@ -13,21 +13,21 @@ import { useLocation, useNavigate, useParams } from "react-router-dom"; import { Icon } from "@/components/ui/Icon"; import { notify } from "@/components/ui/notify"; -import styles from "../Grader.module.css"; -import { DeadlineExceptionDialog } from "../components/DeadlineExceptionDialog"; -import { GradePanel, GradePanelHandle } from "../components/GradePanel"; -import { RegradeWizard } from "../components/RegradeWizard"; -import { ShortcutsHelpDialog } from "../components/ShortcutsHelpDialog"; -import { StudentListSidebar } from "../components/StudentListSidebar"; -import { SubmissionPane, SubmissionPaneHandle } from "../components/SubmissionPane"; -import { AutoSavedInfo, useAutoSaveGrade } from "../hooks/useAutoSaveGrade"; -import { useGraderHotkeys } from "../hooks/useGraderHotkeys"; -import { useGraderPrefs } from "../hooks/useGraderPrefs"; -import { usePlatform } from "../hooks/usePlatform"; -import { useStudentNavigation } from "../hooks/useStudentNavigation"; -import { studentDisplayName } from "../state/graderSelectors"; -import { useGraderTourContext } from "../tour/GraderTourContext"; -import { getDemoAnswersFor, getDemoQuestionsFor } from "../tour/graderDemoData"; +import styles from "../../Grader.module.css"; +import { DeadlineExceptionDialog } from "../../components/DeadlineExceptionDialog"; +import { GradePanel, GradePanelHandle } from "../../components/GradePanel"; +import { RegradeWizard } from "../../components/RegradeWizard"; +import { ShortcutsHelpDialog } from "../../components/ShortcutsHelpDialog"; +import { StudentListSidebar } from "../../components/StudentListSidebar"; +import { SubmissionPane, SubmissionPaneHandle } from "../../components/SubmissionPane"; +import { AutoSavedInfo, useAutoSaveGrade } from "../../hooks/useAutoSaveGrade"; +import { useGraderHotkeys } from "../../hooks/useGraderHotkeys"; +import { useGraderPrefs } from "../../hooks/useGraderPrefs"; +import { usePlatform } from "../../hooks/usePlatform"; +import { useStudentNavigation } from "../../hooks/useStudentNavigation"; +import { studentDisplayName } from "../../state/graderSelectors"; +import { useGraderTourContext } from "../../tour/GraderTourContext"; +import { getDemoAnswersFor, getDemoQuestionsFor } from "../../tour/graderDemoData"; export const GraderQuestionPage: React.FC = () => { const { assignmentId, questionId, sid } = useParams(); @@ -256,7 +256,8 @@ export const GraderQuestionPage: React.FC = () => { }, [autoSave.status, student?.sid]); const [help, setHelp] = useState(false); - const [hideGraded, setHideGraded] = useState(false); + const [hideFullCredit, setHideFullCredit] = useState(false); + const [hideUnanswered, setHideUnanswered] = useState(false); const [showRegrade, setShowRegrade] = useState(false); const [showExtraTime, setShowExtraTime] = useState(false); const gradePanelRef = useRef(null); @@ -277,7 +278,7 @@ export const GraderQuestionPage: React.FC = () => { prevAttempt: () => submissionRef.current?.prevAttempt(), focusGrade: () => gradePanelRef.current?.focusGrade(), focusComment: () => gradePanelRef.current?.focusComment(), - toggleHideGraded: () => setHideGraded((v) => !v), + toggleHideFullCredit: () => setHideFullCredit((v) => !v), openHelp: () => setHelp(true) }); @@ -363,8 +364,10 @@ export const GraderQuestionPage: React.FC = () => { } deadlineEnforced={lateStudentsData?.enforce_due} onSelect={selectSid} - hideGraded={hideGraded} - onToggleHideGraded={setHideGraded} + hideFullCredit={hideFullCredit} + onToggleHideFullCredit={setHideFullCredit} + hideUnanswered={hideUnanswered} + onToggleHideUnanswered={setHideUnanswered} />
diff --git a/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/pages/GraderQuestionPage/index.ts b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/pages/GraderQuestionPage/index.ts new file mode 100644 index 000000000..a57edee5b --- /dev/null +++ b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/pages/GraderQuestionPage/index.ts @@ -0,0 +1 @@ +export { GraderQuestionPage } from "./GraderQuestionPage"; diff --git a/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/pages/GraderQuestionsPage.tsx b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/pages/GraderQuestionsPage/GraderQuestionsPage.tsx similarity index 95% rename from bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/pages/GraderQuestionsPage.tsx rename to bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/pages/GraderQuestionsPage/GraderQuestionsPage.tsx index ab9eae225..9c8f0f957 100644 --- a/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/pages/GraderQuestionsPage.tsx +++ b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/pages/GraderQuestionsPage/GraderQuestionsPage.tsx @@ -8,17 +8,17 @@ import { DataGrid } from "@/components/ui/DataGrid"; import { Icon } from "@/components/ui/Icon"; import { usePersistedPagination } from "@/hooks/usePersistedPagination"; -import styles from "../Grader.module.css"; -import { DeadlineExceptionDialog } from "../components/DeadlineExceptionDialog"; -import { MultiGradeDialog } from "../components/MultiGradeDialog"; -import { RegradeWizard } from "../components/RegradeWizard"; -import { ReleaseGradesControl } from "../components/ReleaseGradesControl"; -import { ThresholdControl } from "../components/ThresholdControl"; -import { GraderViewMode, ViewModeToggle } from "../components/ViewModeToggle"; -import { useViewModeStorage } from "../hooks/useViewModeStorage"; -import { effectiveViewMode, isAutogradeable } from "../state/graderSelectors"; -import { useGraderTourContext } from "../tour/GraderTourContext"; -import { getDemoQuestionsFor } from "../tour/graderDemoData"; +import styles from "../../Grader.module.css"; +import { DeadlineExceptionDialog } from "../../components/DeadlineExceptionDialog"; +import { MultiGradeDialog } from "../../components/MultiGradeDialog"; +import { RegradeWizard } from "../../components/RegradeWizard"; +import { ReleaseGradesControl } from "../../components/ReleaseGradesControl"; +import { ThresholdControl } from "../../components/ThresholdControl"; +import { GraderViewMode, ViewModeToggle } from "../../components/ViewModeToggle"; +import { useViewModeStorage } from "../../hooks/useViewModeStorage"; +import { effectiveViewMode, isAutogradeable } from "../../state/graderSelectors"; +import { useGraderTourContext } from "../../tour/GraderTourContext"; +import { getDemoQuestionsFor } from "../../tour/graderDemoData"; const friendlyType = (t: string) => { const map: Record = { diff --git a/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/pages/GraderQuestionsPage/index.ts b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/pages/GraderQuestionsPage/index.ts new file mode 100644 index 000000000..cbb35720a --- /dev/null +++ b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/pages/GraderQuestionsPage/index.ts @@ -0,0 +1 @@ +export { GraderQuestionsPage } from "./GraderQuestionsPage"; diff --git a/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/pages/GraderQuestionsPage.spec.tsx b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/pages/GraderQuestionsPage/spec/GraderQuestionsPage.test.tsx similarity index 87% rename from bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/pages/GraderQuestionsPage.spec.tsx rename to bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/pages/GraderQuestionsPage/spec/GraderQuestionsPage.test.tsx index 1b7f69dff..a3b208a71 100644 --- a/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/pages/GraderQuestionsPage.spec.tsx +++ b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/pages/GraderQuestionsPage/spec/GraderQuestionsPage.test.tsx @@ -3,7 +3,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { fireEvent, renderWithMantine, screen } from "@/test/renderWithMantine"; -import { GraderQuestionsPage } from "./GraderQuestionsPage"; +import { GraderQuestionsPage } from "../GraderQuestionsPage"; const { mockUseGetGraderQuestionsQuery, mockParams } = vi.hoisted(() => ({ mockUseGetGraderQuestionsQuery: vi.fn(), @@ -26,11 +26,13 @@ vi.mock("react-router-dom", () => ({ // These own their own queries and dialogs; the page only decides whether to // show them, which their own specs cover. -vi.mock("../components/ThresholdControl", () => ({ ThresholdControl: () => null })); -vi.mock("../components/ReleaseGradesControl", () => ({ ReleaseGradesControl: () => null })); -vi.mock("../components/RegradeWizard", () => ({ RegradeWizard: () => null })); -vi.mock("../components/MultiGradeDialog", () => ({ MultiGradeDialog: () => null })); -vi.mock("../components/DeadlineExceptionDialog", () => ({ DeadlineExceptionDialog: () => null })); +vi.mock("../../../components/ThresholdControl", () => ({ ThresholdControl: () => null })); +vi.mock("../../../components/ReleaseGradesControl", () => ({ ReleaseGradesControl: () => null })); +vi.mock("../../../components/RegradeWizard", () => ({ RegradeWizard: () => null })); +vi.mock("../../../components/MultiGradeDialog", () => ({ MultiGradeDialog: () => null })); +vi.mock("../../../components/DeadlineExceptionDialog", () => ({ + DeadlineExceptionDialog: () => null +})); const makeQuestions = (count: number): GraderQuestionsResponse => ({ assignment: { id: 1, name: "Quiz 1", points: 10 }, diff --git a/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/pages/index.ts b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/pages/index.ts new file mode 100644 index 000000000..140f8852b --- /dev/null +++ b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/pages/index.ts @@ -0,0 +1,4 @@ +export { GraderAssignmentsPage } from "./GraderAssignmentsPage"; +export { GraderGradebookPage } from "./GraderGradebookPage"; +export { GraderQuestionPage } from "./GraderQuestionPage"; +export { GraderQuestionsPage } from "./GraderQuestionsPage"; diff --git a/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/tour/graderTourConfig.ts b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/tour/graderTourConfig.ts index 073615f13..3df56d5d2 100644 --- a/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/tour/graderTourConfig.ts +++ b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/tour/graderTourConfig.ts @@ -147,7 +147,7 @@ export const GRADER_TOUR_STEPS: TourStepConfig[] = [ element: '[data-tour="grader-student-sidebar"]', title: "Student sidebar", description: - "All students who attempted the question. Status icons show progress: ✓ graded, ⚡ auto-graded, ◐ in progress, ○ pending, – no submission. Filter, search, or press H to hide already-graded students.", + "Students enrolled in the course. Status icons show progress: ✓ graded, ⚡ auto-graded, ◐ in progress, ○ pending, – no submission. Search the list, hide students with full credit or no submission, or press H to toggle the full-credit filter.", side: "right", align: "start" }, From f3f1faa9a8fe5256241b44568a5dee6463708aaf Mon Sep 17 00:00:00 2001 From: Andrey Morozov Date: Tue, 6 Oct 2026 20:40:35 +0200 Subject: [PATCH 2/2] f-1569 create separate toolbar --- .../routes/Grader/Grader.module.css | 11 -- .../GraderAssignmentsPage.tsx | 22 ++-- .../GraderGradebookPage.tsx | 69 +++++----- .../GraderQuestionsPage.tsx | 120 +++++++++--------- .../ui/TabToolbar/TabToolbar.module.css | 14 +- .../components/ui/TabToolbar/TabToolbar.tsx | 24 ++-- .../components/ui/Toolbar/Toolbar.module.css | 38 ++++++ .../src/components/ui/Toolbar/Toolbar.tsx | 19 +++ .../ui/Toolbar/spec/Toolbar.spec.tsx | 28 ++++ 9 files changed, 217 insertions(+), 128 deletions(-) create mode 100644 bases/rsptx/assignment_server_api/assignment_builder/src/components/ui/Toolbar/Toolbar.module.css create mode 100644 bases/rsptx/assignment_server_api/assignment_builder/src/components/ui/Toolbar/Toolbar.tsx create mode 100644 bases/rsptx/assignment_server_api/assignment_builder/src/components/ui/Toolbar/spec/Toolbar.spec.tsx diff --git a/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/Grader.module.css b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/Grader.module.css index 5a394554e..41828e4cb 100644 --- a/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/Grader.module.css +++ b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/Grader.module.css @@ -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; diff --git a/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/pages/GraderAssignmentsPage/GraderAssignmentsPage.tsx b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/pages/GraderAssignmentsPage/GraderAssignmentsPage.tsx index 2997b6dcc..937e1cdd0 100644 --- a/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/pages/GraderAssignmentsPage/GraderAssignmentsPage.tsx +++ b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/pages/GraderAssignmentsPage/GraderAssignmentsPage.tsx @@ -8,6 +8,7 @@ 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"; @@ -226,15 +227,18 @@ export const GraderAssignmentsPage: React.FC = () => { : allRows; const viewToggle = ( -
- -
+ + } + /> ); if (activeViewMode === "table") { diff --git a/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/pages/GraderGradebookPage/GraderGradebookPage.tsx b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/pages/GraderGradebookPage/GraderGradebookPage.tsx index a54388cfc..00b92945b 100644 --- a/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/pages/GraderGradebookPage/GraderGradebookPage.tsx +++ b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/pages/GraderGradebookPage/GraderGradebookPage.tsx @@ -14,6 +14,7 @@ import { gradebookCsvFilename, useGetGradebookQuery } from "@store/grader/grader import React, { useMemo, useState } from "react"; import { Icon } from "@/components/ui/Icon"; +import { Toolbar } from "@/components/ui/Toolbar/Toolbar"; import { ErrorState } from "../../../AssignmentBuilder/components/ErrorState/ErrorState"; import styles from "../../Grader.module.css"; @@ -173,10 +174,11 @@ export const GraderGradebookPage: React.FC = () => { if (allAssignments.length === 0 || allStudents.length === 0) { return ( <> -
- Gradebook -
{exportButton}
-
+ Gradebook} + end={exportButton} + />

Nothing to grade yet

@@ -196,32 +198,39 @@ export const GraderGradebookPage: React.FC = () => { return ( <> -
-
- setStudentQuery(e.currentTarget.value)} - leftSection={} - className={styles.gradebookFilterInput} - /> - - - {exportButton} -
-
+ + setStudentQuery(e.currentTarget.value)} + leftSection={} + className={styles.gradebookFilterInput} + /> + + + } + end={ + <> + + {exportButton} + + } + /> Showing {students.length} of {allStudents.length}{" "} diff --git a/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/pages/GraderQuestionsPage/GraderQuestionsPage.tsx b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/pages/GraderQuestionsPage/GraderQuestionsPage.tsx index 9c8f0f957..c159a1b5a 100644 --- a/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/pages/GraderQuestionsPage/GraderQuestionsPage.tsx +++ b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/pages/GraderQuestionsPage/GraderQuestionsPage.tsx @@ -6,6 +6,7 @@ import { useNavigate, useParams } 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 styles from "../../Grader.module.css"; @@ -364,68 +365,73 @@ export const GraderQuestionsPage: React.FC = () => { ); const toolbar = ( -
-
- {activeViewMode === "cards" && ( - - )} - - - - + + {activeViewMode === "cards" && ( + + )} + + + + + + - - - {hasSelection && ( - - {selectedQuestionIds.length} selected - {selectedQuestionIds.length > autogradeableCount && ( - - {selectedQuestionIds.length - autogradeableCount} not auto-gradeable - - )} - - - )} -
-
- {!isDemo && } - {!isDemo && } - {viewToggle} -
-
+ {hasSelection && ( + + {selectedQuestionIds.length} selected + {selectedQuestionIds.length > autogradeableCount && ( + + {selectedQuestionIds.length - autogradeableCount} not auto-gradeable + + )} + + + )} + + } + end={ + <> + {!isDemo && } + {!isDemo && } + {viewToggle} + + } + /> ); const dialogs = ( diff --git a/bases/rsptx/assignment_server_api/assignment_builder/src/components/ui/TabToolbar/TabToolbar.module.css b/bases/rsptx/assignment_server_api/assignment_builder/src/components/ui/TabToolbar/TabToolbar.module.css index 1ab0cb8b8..1ef4562f2 100644 --- a/bases/rsptx/assignment_server_api/assignment_builder/src/components/ui/TabToolbar/TabToolbar.module.css +++ b/bases/rsptx/assignment_server_api/assignment_builder/src/components/ui/TabToolbar/TabToolbar.module.css @@ -1,9 +1,4 @@ .toolbar { - display: flex; - align-items: center; - justify-content: space-between; - gap: var(--rs-space-4); - flex-wrap: wrap; padding: var(--rs-space-3) var(--rs-space-4); background: var(--rs-surface); border-bottom: 1px solid var(--rs-border-solid); @@ -20,6 +15,8 @@ display: flex; align-items: center; gap: var(--rs-space-2); + min-width: 0; + flex-wrap: wrap; } .title { @@ -39,10 +36,3 @@ border-radius: var(--rs-radius-pill); padding: 1px 10px; } - -.actions { - display: flex; - align-items: center; - gap: var(--rs-space-3); - flex-wrap: wrap; -} diff --git a/bases/rsptx/assignment_server_api/assignment_builder/src/components/ui/TabToolbar/TabToolbar.tsx b/bases/rsptx/assignment_server_api/assignment_builder/src/components/ui/TabToolbar/TabToolbar.tsx index eff89853d..429304363 100644 --- a/bases/rsptx/assignment_server_api/assignment_builder/src/components/ui/TabToolbar/TabToolbar.tsx +++ b/bases/rsptx/assignment_server_api/assignment_builder/src/components/ui/TabToolbar/TabToolbar.tsx @@ -1,5 +1,7 @@ import { ReactNode } from "react"; +import { Toolbar } from "../Toolbar/Toolbar"; + import styles from "./TabToolbar.module.css"; interface TabToolbarProps { @@ -20,14 +22,18 @@ export const TabToolbar = ({ children }: TabToolbarProps) => { return ( -
-
- {leading} -

{title}

- {count} - {titleExtra} -
-
{children}
-
+ + {leading} +

{title}

+ {count} + {titleExtra} +
+ } + end={children} + /> ); }; diff --git a/bases/rsptx/assignment_server_api/assignment_builder/src/components/ui/Toolbar/Toolbar.module.css b/bases/rsptx/assignment_server_api/assignment_builder/src/components/ui/Toolbar/Toolbar.module.css new file mode 100644 index 000000000..d01accae3 --- /dev/null +++ b/bases/rsptx/assignment_server_api/assignment_builder/src/components/ui/Toolbar/Toolbar.module.css @@ -0,0 +1,38 @@ +.toolbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--rs-space-3); + width: 100%; +} + +.start, +.end { + display: flex; + align-items: center; + gap: var(--rs-space-2); + min-width: 0; + flex-wrap: wrap; +} + +.start { + flex: 1 1 auto; +} + +.end { + flex: 0 1 auto; + justify-content: flex-end; + margin-left: auto; +} + +@media (max-width: 900px) { + .toolbar { + flex-direction: column; + align-items: stretch; + } + + .end { + justify-content: flex-start; + margin-left: 0; + } +} diff --git a/bases/rsptx/assignment_server_api/assignment_builder/src/components/ui/Toolbar/Toolbar.tsx b/bases/rsptx/assignment_server_api/assignment_builder/src/components/ui/Toolbar/Toolbar.tsx new file mode 100644 index 000000000..0ddaad5e6 --- /dev/null +++ b/bases/rsptx/assignment_server_api/assignment_builder/src/components/ui/Toolbar/Toolbar.tsx @@ -0,0 +1,19 @@ +import { HTMLAttributes, ReactNode } from "react"; + +import styles from "./Toolbar.module.css"; + +interface ToolbarProps extends Omit, "children"> { + start?: ReactNode; + end?: ReactNode; +} + +export const Toolbar = ({ start, end, className, ...props }: ToolbarProps) => { + const toolbarClassName = [styles.toolbar, className].filter(Boolean).join(" "); + + return ( +
+ {start != null &&
{start}
} + {end != null &&
{end}
} +
+ ); +}; diff --git a/bases/rsptx/assignment_server_api/assignment_builder/src/components/ui/Toolbar/spec/Toolbar.spec.tsx b/bases/rsptx/assignment_server_api/assignment_builder/src/components/ui/Toolbar/spec/Toolbar.spec.tsx new file mode 100644 index 000000000..7312376e6 --- /dev/null +++ b/bases/rsptx/assignment_server_api/assignment_builder/src/components/ui/Toolbar/spec/Toolbar.spec.tsx @@ -0,0 +1,28 @@ +import { render, screen } from "@testing-library/react"; + +import { Toolbar } from "../Toolbar"; + +describe("Toolbar", () => { + it("renders the start and end sections in order", () => { + render(Filters} end={} />); + + const filters = screen.getByText("Filters"); + const exportButton = screen.getByRole("button", { name: "Export" }); + + expect( + filters.compareDocumentPosition(exportButton) & Node.DOCUMENT_POSITION_FOLLOWING + ).toBeTruthy(); + }); + + it("omits sections that were not provided", () => { + const { container } = render(Filters} />); + + expect(container.firstElementChild?.childElementCount).toBe(1); + }); + + it("forwards div attributes and custom classes", () => { + render(); + + expect(screen.getByLabelText("Student filters")).toHaveClass("page-toolbar"); + }); +});