diff --git a/bases/rsptx/assignment_server_api/assignment_builder/e2e/course-switcher.spec.ts b/bases/rsptx/assignment_server_api/assignment_builder/e2e/course-switcher.spec.ts new file mode 100644 index 000000000..6606bbd11 --- /dev/null +++ b/bases/rsptx/assignment_server_api/assignment_builder/e2e/course-switcher.spec.ts @@ -0,0 +1,237 @@ +import { APIRequestContext, expect, test } from "@playwright/test"; + +import { gotoApp } from "./fixtures/appNav"; +import { findGradableQuestion, gotoSplitView } from "./fixtures/grader"; + +interface InstructorCourse { + course_name: string; + is_current: boolean; +} + +const SWITCH_ENDPOINT = "/assignment/instructor/courses/switch"; +const HISTORY_COURSE_KEY = "__runestoneCourse"; +const ACTIVE_COURSE_KEY = "assignment-builder.active-course"; +const COURSE_SWITCH_ENABLED = process.env.E2E_COURSE_SWITCH === "1"; +const REQUESTED_NESTED_PATH = process.env.E2E_COURSE_SWITCH_PATH; +const RESTORE_COURSE = process.env.E2E_RESTORE_COURSE; + +const switchCourseViaApi = async (request: APIRequestContext, course: string) => { + const response = await request.post(SWITCH_ENDPOINT, { data: { course_name: course } }); + + expect(response.ok()).toBe(true); +}; + +test( + "course switching keeps Back and Forward on the new grader course", + { tag: ["@p1", "@grader", "@course-switcher"] }, + async ({ page, request }) => { + test.setTimeout(90_000); + test.skip( + !COURSE_SWITCH_ENABLED, + "Set E2E_COURSE_SWITCH=1 and run this file alone with --workers=1; it changes shared user state." + ); + + const coursesResponse = await request.get("/assignment/instructor/courses/recent"); + + expect(coursesResponse.ok()).toBe(true); + const coursesBody = (await coursesResponse.json()) as { + detail: { courses: InstructorCourse[] }; + }; + const original = coursesBody.detail.courses.find(({ is_current }) => is_current); + + test.skip( + !original || coursesBody.detail.courses.length < 2, + "The E2E instructor must teach at least two courses." + ); + + let source: InstructorCourse | undefined; + let question: Awaited> | undefined; + + try { + for (const course of coursesBody.detail.courses) { + await switchCourseViaApi(request, course.course_name); + + try { + question = await findGradableQuestion(request, 1); + source = course; + break; + } catch { + // Try the next recent course; local E2E data may be course-specific. + } + } + + if (!source || !question) { + throw new Error("At least one recent course must contain gradable test data."); + } + + const target = coursesBody.detail.courses.find( + ({ course_name }) => course_name !== source.course_name + ); + + if (!target) throw new Error("A second recent course is required for course switching."); + + const requestedNestedPath = REQUESTED_NESTED_PATH; + + await page.goto("/ns/course/index"); + await expect(page).toHaveURL(/\/ns\/course\/index\/?$/); + await gotoApp(page, requestedNestedPath ?? `/grader/${question.assignmentId}`); + await expect + .poll(() => page.evaluate((key) => window.history.state?.[key], HISTORY_COURSE_KEY)) + .toBe(source.course_name); + if (!requestedNestedPath) { + await gotoSplitView(page, question); + await expect + .poll(() => page.evaluate((key) => window.history.state?.[key], HISTORY_COURSE_KEY)) + .toBe(source.course_name); + } + + await page.getByRole("button", { name: `Course: ${source.course_name}` }).click(); + + const switchResponse = page.waitForResponse( + (response) => + response.url().endsWith(SWITCH_ENDPOINT) && response.request().method() === "POST" + ); + + await page.getByRole("menuitem", { name: target.course_name, exact: true }).click(); + expect((await switchResponse).ok()).toBe(true); + + await expect(page).toHaveURL(/\/assignment\/instructor\/grader\/?$/); + await expect( + page.getByRole("button", { name: `Course: ${target.course_name}` }) + ).toBeVisible(); + + if (!requestedNestedPath) { + await page.goBack({ waitUntil: "commit" }); + + await expect + .poll(() => + page + .evaluate( + ({ activeKey, historyKey }) => ({ + configuredCourse: window.eBookConfig.course, + storedCourse: window.sessionStorage.getItem(activeKey), + historyCourse: window.history.state?.[historyKey] + }), + { activeKey: ACTIVE_COURSE_KEY, historyKey: HISTORY_COURSE_KEY } + ) + .catch(() => null) + ) + .toEqual({ + configuredCourse: target.course_name, + storedCourse: target.course_name, + historyCourse: target.course_name + }); + await expect(page).toHaveURL(/\/assignment\/instructor\/grader\/?$/); + } + + if (requestedNestedPath) { + await page.goBack({ waitUntil: "domcontentloaded" }); + } else { + for (let attempt = 0; attempt < 5 && !page.url().endsWith("/ns/course/index"); attempt++) { + await page.goBack({ waitUntil: "domcontentloaded" }); + } + } + + await expect(page).toHaveURL(/\/ns\/course\/index\/?$/); + await page.goForward({ waitUntil: "commit" }); + await expect(page).toHaveURL(/\/assignment\/instructor\/grader\/?$/); + await expect( + page.getByRole("button", { name: `Course: ${target.course_name}` }) + ).toBeVisible(); + await expect + .poll(() => + page + .evaluate( + ({ activeKey, historyKey }) => ({ + configuredCourse: window.eBookConfig.course, + storedCourse: window.sessionStorage.getItem(activeKey), + historyCourse: window.history.state?.[historyKey] + }), + { activeKey: ACTIVE_COURSE_KEY, historyKey: HISTORY_COURSE_KEY } + ) + .catch(() => null) + ) + .toEqual({ + configuredCourse: target.course_name, + storedCourse: target.course_name, + historyCourse: target.course_name + }); + await expect + .poll(async () => { + const response = await request.get("/assignment/instructor/courses/recent"); + const body = (await response.json()) as { + detail: { courses: InstructorCourse[] }; + }; + + return body.detail.courses.find(({ is_current }) => is_current)?.course_name; + }) + .toBe(target.course_name); + + await page.goBack({ waitUntil: "domcontentloaded" }); + await expect(page).toHaveURL(/\/ns\/course\/index\/?$/); + await page.goForward({ waitUntil: "commit" }); + await expect(page).toHaveURL(/\/assignment\/instructor\/grader\/?$/); + await expect( + page.getByRole("button", { name: `Course: ${target.course_name}` }) + ).toBeVisible(); + } finally { + const restoreCourse = RESTORE_COURSE ?? original?.course_name; + + if (restoreCourse) await switchCourseViaApi(request, restoreCourse); + } + } +); + +for (const scenario of [ + { + name: "nested builder", + startPath: "/assignment/instructor/builder/22", + destination: /\/assignment\/instructor\/builder\/?$/ + }, + { + name: "gradebook", + startPath: "/assignment/instructor/gradebook", + destination: /\/assignment\/instructor\/grader\/?$/ + } +]) { + test( + `switching from ${scenario.name} uses the section landing page`, + { tag: ["@p1", "@course-switcher"] }, + async ({ page, request }) => { + test.skip( + !COURSE_SWITCH_ENABLED, + "Set E2E_COURSE_SWITCH=1 and run this file alone with --workers=1; it changes shared user state." + ); + + const coursesResponse = await request.get("/assignment/instructor/courses/recent"); + const coursesBody = (await coursesResponse.json()) as { + detail: { courses: InstructorCourse[] }; + }; + const original = coursesBody.detail.courses.find(({ is_current }) => is_current); + const target = coursesBody.detail.courses.find(({ is_current }) => !is_current); + + test.skip(!original || !target, "The E2E instructor must teach at least two courses."); + + try { + await page.goto(scenario.startPath); + await page.getByRole("button", { name: `Course: ${original!.course_name}` }).click(); + + const switchResponse = page.waitForResponse( + (response) => + response.url().endsWith(SWITCH_ENDPOINT) && response.request().method() === "POST" + ); + + await page.getByRole("menuitem", { name: target!.course_name, exact: true }).click(); + expect((await switchResponse).ok()).toBe(true); + await expect(page).toHaveURL(scenario.destination); + await expect( + page.getByRole("button", { name: `Course: ${target!.course_name}` }) + ).toBeVisible(); + } finally { + const restoreCourse = RESTORE_COURSE ?? original?.course_name; + + if (restoreCourse) await switchCourseViaApi(request, restoreCourse); + } + } + ); +} diff --git a/bases/rsptx/assignment_server_api/assignment_builder/e2e/fixtures/grader.ts b/bases/rsptx/assignment_server_api/assignment_builder/e2e/fixtures/grader.ts index b61c6e236..b76da767d 100644 --- a/bases/rsptx/assignment_server_api/assignment_builder/e2e/fixtures/grader.ts +++ b/bases/rsptx/assignment_server_api/assignment_builder/e2e/fixtures/grader.ts @@ -10,11 +10,13 @@ export interface GradableQuestion { answeredCount: number; } -const SEEDED_ASSIGNMENT_IDS = [1, 2, 3, 4, 5, 7, 8]; - const graderQuestionsApi = (assignmentId: number) => `/assignment/instructor/grader/assignments/${assignmentId}/questions`; +interface AssignmentListDetail { + assignments: Array<{ id: number }>; +} + interface GraderQuestionsDetail { assignment: { id: number; name: string }; questions: Array<{ id: number; name: string; question_type: string; answered_count: number }>; @@ -24,13 +26,27 @@ export const findGradableQuestion = async ( request: APIRequestContext, minStudents = 2 ): Promise => { - for (const assignmentId of SEEDED_ASSIGNMENT_IDS) { + const assignmentsResponse = await request.get("/assignment/instructor/assignments"); + + if (!assignmentsResponse.ok()) { + throw new Error(`Could not load assignments: HTTP ${assignmentsResponse.status()}`); + } + + const assignmentsBody = (await assignmentsResponse.json()) as { + detail?: AssignmentListDetail; + }; + const assignmentIds = assignmentsBody.detail?.assignments.map(({ id }) => id) ?? []; + + for (const assignmentId of assignmentIds) { const res = await request.get(graderQuestionsApi(assignmentId)); + if (!res.ok()) continue; const body = (await res.json()) as { detail?: GraderQuestionsDetail }; const detail = body.detail; + if (!detail?.questions?.length) continue; const hit = detail.questions.find((q) => q.answered_count >= minStudents); + if (hit) { return { assignmentId, @@ -42,8 +58,8 @@ export const findGradableQuestion = async ( } } throw new Error( - `No seeded question with >= ${minStudents} student submissions found in assignments ` + - `${SEEDED_ASSIGNMENT_IDS.join(", ")} — the grader read-only suite needs restored seed data.` + `No question with >= ${minStudents} student submissions found in assignments ` + + `${assignmentIds.join(", ")} — the grader read-only suite needs suitable test data.` ); }; @@ -54,8 +70,10 @@ export const trackInstructorWrites = (page: Page): string[] => { page.on("request", (request) => { const method = request.method(); + if (method !== "POST" && method !== "PUT" && method !== "DELETE") return; const url = request.url(); + if (!url.includes("/assignment/instructor/")) return; if (method === "POST" && READ_MODELED_POSTS.some((fragment) => url.includes(fragment))) return; writes.push(`${method} ${url}`); @@ -76,6 +94,7 @@ export const gotoSplitView = async ( export const activeStudentSid = (page: Page): string => { const match = page.url().match(/\/students\/([^/?#]+)/); + if (!match) throw new Error(`No student sid in URL: ${page.url()}`); return decodeURIComponent(match[1]); }; diff --git a/bases/rsptx/assignment_server_api/assignment_builder/src/App.tsx b/bases/rsptx/assignment_server_api/assignment_builder/src/App.tsx index 2ce34e516..e99f7acd4 100644 --- a/bases/rsptx/assignment_server_api/assignment_builder/src/App.tsx +++ b/bases/rsptx/assignment_server_api/assignment_builder/src/App.tsx @@ -1,5 +1,10 @@ import { AppNavBar } from "@components/shell/AppNavBar"; import { useScrollShadow } from "@components/shell/useScrollShadow"; +import { + useGetRecentInstructorCoursesQuery, + useSwitchInstructorCourseMutation +} from "@store/course/course.logic.api"; +import { useEffect } from "react"; import { useSelector } from "react-redux"; import { Navigate, @@ -14,7 +19,13 @@ import "./App.css"; import { routerService } from "@/router"; import shellStyles from "./components/shell/AppShell.module.css"; -import { buildNavBar } from "./navUtils.js"; +import { + getRestoredCourseDestination, + markCourseSwitch, + reconcileServerCourse, + syncCourseHistoryEntry +} from "./courseHistory"; +import { buildNavBar, getCourseSwitchDestination } from "./navUtils.js"; import AssignmentEditor, { AddQuestionTabGroup, MoreOptions } from "./renderers/assignment.jsx"; import { AssignmentPicker } from "./renderers/assignmentPicker.jsx"; import { @@ -73,7 +84,85 @@ function AppContent() { const navigate = useNavigate(); const location = useLocation(); const { sentinelRef, scrolled } = useScrollShadow(); - const items = buildNavBar(window.eBookConfig, navigate); + const { data: instructorCourses = [], refetch: refetchInstructorCourses } = + useGetRecentInstructorCoursesQuery(undefined, { + skip: window.eBookConfig.isInstructor === false + }); + const [switchInstructorCourse, { isLoading: isSwitchingCourse }] = + useSwitchInstructorCourseMutation(); + const currentCourse = window.eBookConfig.course ?? ""; + const serverCourse = instructorCourses.find(({ is_current }) => is_current)?.course_name; + + useEffect(() => { + const redirectToServerCourse = (courseName: string | undefined): boolean => { + const destination = reconcileServerCourse(currentCourse, courseName, location.pathname); + + if (!destination) return false; + + window.location.replace(destination); + return true; + }; + + if (redirectToServerCourse(serverCourse)) return; + + const destination = syncCourseHistoryEntry(currentCourse, location.pathname); + + if (destination) { + window.location.replace(destination); + return; + } + + let cancelled = false; + const handlePageShow = async (event: PageTransitionEvent) => { + if (event.persisted && window.eBookConfig.isInstructor !== false) { + try { + const refreshed = await refetchInstructorCourses(); + + if (cancelled) return; + + const restoredServerCourse = refreshed.data?.find( + ({ is_current }) => is_current + )?.course_name; + + if (redirectToServerCourse(restoredServerCourse)) return; + } catch { + // Fall back to the local history checks when the refresh is unavailable. + } + } + + const restoredDestination = getRestoredCourseDestination( + event.persisted, + currentCourse, + location.pathname + ); + + if (restoredDestination) { + window.location.replace(restoredDestination); + } + }; + + window.addEventListener("pageshow", handlePageShow); + + return () => { + cancelled = true; + window.removeEventListener("pageshow", handlePageShow); + }; + }, [currentCourse, location.key, location.pathname, refetchInstructorCourses, serverCourse]); + + const handleCourseSwitch = async (courseName: string) => { + try { + await switchInstructorCourse(courseName).unwrap(); + markCourseSwitch(courseName); + window.location.replace(getCourseSwitchDestination(location.pathname)); + } catch { + return; + } + }; + const items = buildNavBar(window.eBookConfig, navigate, { + courses: instructorCourses, + onSwitch: handleCourseSwitch, + isSwitching: isSwitchingCourse + }); const isFullBleedRoute = FULL_BLEED_ROUTE.test(location.pathname); return ( diff --git a/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/pages/GraderQuestionPage/GraderQuestionPage.tsx b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/pages/GraderQuestionPage/GraderQuestionPage.tsx index 6914ba97f..67f6387f5 100644 --- a/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/pages/GraderQuestionPage/GraderQuestionPage.tsx +++ b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/pages/GraderQuestionPage/GraderQuestionPage.tsx @@ -12,6 +12,7 @@ import { useLocation, useNavigate, useParams } from "react-router-dom"; import { Icon } from "@/components/ui/Icon"; import { notify } from "@/components/ui/notify"; +import { isNotFoundError } from "@/utils/apiError"; import styles from "../../Grader.module.css"; import { DeadlineExceptionDialog } from "../../components/DeadlineExceptionDialog"; @@ -39,10 +40,14 @@ export const GraderQuestionPage: React.FC = () => { const { isDemo, demoSelected, setDemoSelected } = useGraderTourContext(); - const { data: qRealData } = useGetGraderQuestionsQuery(aid, { + const { data: qRealData, error: questionsError } = useGetGraderQuestionsQuery(aid, { skip: !aid || isDemo }); - const { data: realData, isLoading } = useGetGraderAnswersQuery( + const { + data: realData, + error: answersError, + isLoading + } = useGetGraderAnswersQuery( { assignmentId: aid, questionId: qid }, { skip: !aid || !qid || isDemo } ); @@ -52,6 +57,12 @@ export const GraderQuestionPage: React.FC = () => { const data = isDemo ? (getDemoAnswersFor(aid, qid) ?? undefined) : realData; const qData = isDemo ? (getDemoQuestionsFor(aid) ?? undefined) : qRealData; const questionMeta = qData?.questions.find((q) => q.id === qid); + + useEffect(() => { + if (isNotFoundError(questionsError) || isNotFoundError(answersError)) { + navigate("/grader", { replace: true }); + } + }, [answersError, navigate, questionsError]); const lateStudentsBySid = useMemo( () => new Map( 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 c159a1b5a..b50ee58a6 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 @@ -8,6 +8,7 @@ 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 { isNotFoundError } from "@/utils/apiError"; import styles from "../../Grader.module.css"; import { DeadlineExceptionDialog } from "../../components/DeadlineExceptionDialog"; @@ -112,12 +113,22 @@ export const GraderQuestionsPage: React.FC = () => { const id = Number(assignmentId); const navigate = useNavigate(); const { isDemo } = useGraderTourContext(); - const { data: realData, isLoading } = useGetGraderQuestionsQuery(id, { + const { + data: realData, + error, + isLoading + } = useGetGraderQuestionsQuery(id, { skip: !id || isDemo, refetchOnMountOrArgChange: true }); const data = isDemo ? (getDemoQuestionsFor(id) ?? undefined) : realData; + useEffect(() => { + if (isNotFoundError(error)) { + navigate("/grader", { replace: true }); + } + }, [error, navigate]); + const [viewMode, setViewMode] = useViewModeStorage( VIEW_MODE_STORAGE_KEY, VIEW_MODES, diff --git a/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/pages/GraderQuestionsPage/spec/GraderQuestionsPage.test.tsx b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/pages/GraderQuestionsPage/spec/GraderQuestionsPage.test.tsx index a3b208a71..d31a6637a 100644 --- a/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/pages/GraderQuestionsPage/spec/GraderQuestionsPage.test.tsx +++ b/bases/rsptx/assignment_server_api/assignment_builder/src/components/routes/Grader/pages/GraderQuestionsPage/spec/GraderQuestionsPage.test.tsx @@ -1,11 +1,12 @@ import type { GraderQuestionsResponse } from "@store/grader/grader.logic.api"; import { beforeEach, describe, expect, it, vi } from "vitest"; -import { fireEvent, renderWithMantine, screen } from "@/test/renderWithMantine"; +import { fireEvent, renderWithMantine, screen, waitFor } from "@/test/renderWithMantine"; import { GraderQuestionsPage } from "../GraderQuestionsPage"; -const { mockUseGetGraderQuestionsQuery, mockParams } = vi.hoisted(() => ({ +const { mockNavigate, mockUseGetGraderQuestionsQuery, mockParams } = vi.hoisted(() => ({ + mockNavigate: vi.fn(), mockUseGetGraderQuestionsQuery: vi.fn(), mockParams: { assignmentId: "1" } })); @@ -20,7 +21,7 @@ vi.mock("@store/grader/grader.logic.api", async (importOriginal) => { }); vi.mock("react-router-dom", () => ({ - useNavigate: () => vi.fn(), + useNavigate: () => mockNavigate, useParams: () => mockParams })); @@ -51,6 +52,7 @@ const makeQuestions = (count: number): GraderQuestionsResponse => ({ describe("GraderQuestionsPage table pagination", () => { beforeEach(() => { localStorage.clear(); + mockNavigate.mockReset(); mockParams.assignmentId = "1"; // The table view is the one with a pager; cards are the default. localStorage.setItem("grader.questionsViewMode", "table"); @@ -117,4 +119,16 @@ describe("GraderQuestionsPage table pagination", () => { expect(screen.getByText("Question 01")).toBeInTheDocument(); }); + + it("returns to the assignment list when the assignment belongs to another course", async () => { + mockUseGetGraderQuestionsQuery.mockReturnValue({ + data: undefined, + error: { status: 404 }, + isLoading: false + }); + + renderWithMantine(); + + await waitFor(() => expect(mockNavigate).toHaveBeenCalledWith("/grader", { replace: true })); + }); }); diff --git a/bases/rsptx/assignment_server_api/assignment_builder/src/components/shell/AppNavBar.spec.tsx b/bases/rsptx/assignment_server_api/assignment_builder/src/components/shell/AppNavBar.spec.tsx index 490982d59..8914eb6da 100644 --- a/bases/rsptx/assignment_server_api/assignment_builder/src/components/shell/AppNavBar.spec.tsx +++ b/bases/rsptx/assignment_server_api/assignment_builder/src/components/shell/AppNavBar.spec.tsx @@ -80,6 +80,22 @@ describe("AppNavBar", () => { expect(screen.queryByRole("button", { name: /gone/i })).not.toBeInTheDocument(); }); + it("does not invoke disabled course items", async () => { + const command = vi.fn(); + const items: NavItem[] = [ + { + label: "Course: cs101", + items: [{ label: "cs102", command, disabled: true }] + } + ]; + + renderWithMantine(); + await userEvent.click(screen.getByRole("button", { name: /course: cs101/i })); + await userEvent.click(await screen.findByRole("menuitem", { name: /cs102/i })); + + expect(command).not.toHaveBeenCalled(); + }); + it("renders the Runestone logo image alongside the wordmark", () => { const { container } = renderWithMantine(); diff --git a/bases/rsptx/assignment_server_api/assignment_builder/src/components/shell/AppNavBar.tsx b/bases/rsptx/assignment_server_api/assignment_builder/src/components/shell/AppNavBar.tsx index aa0a852eb..02d67955f 100644 --- a/bases/rsptx/assignment_server_api/assignment_builder/src/components/shell/AppNavBar.tsx +++ b/bases/rsptx/assignment_server_api/assignment_builder/src/components/shell/AppNavBar.tsx @@ -25,7 +25,7 @@ const renderSubItem = (item: NavItem, index: number) => { {item.label} @@ -104,7 +104,7 @@ const renderCompactItem = (item: NavItem, index: number, activePath: string) => key={item.label ?? index} className={active ? styles.menuItemActive : undefined} leftSection={leftSection} - disabled={!item.command} + disabled={item.disabled || !item.command} onClick={item.command} aria-current={active ? "page" : undefined} > diff --git a/bases/rsptx/assignment_server_api/assignment_builder/src/courseHistory.spec.ts b/bases/rsptx/assignment_server_api/assignment_builder/src/courseHistory.spec.ts new file mode 100644 index 000000000..9d02c9f4a --- /dev/null +++ b/bases/rsptx/assignment_server_api/assignment_builder/src/courseHistory.spec.ts @@ -0,0 +1,124 @@ +import { + getRestoredCourseDestination, + markCourseSwitch, + reconcileServerCourse, + syncCourseHistoryEntry +} from "./courseHistory"; + +const HISTORY_COURSE_KEY = "__runestoneCourse"; + +describe("course-aware browser history", () => { + beforeEach(() => { + sessionStorage.clear(); + window.history.replaceState({ key: "router-key", idx: 2 }, "", "/grader/22"); + }); + + it("stamps a history entry while preserving router state", () => { + expect(syncCourseHistoryEntry("course-1", "/grader/22")).toBeNull(); + expect(window.history.state).toEqual({ + key: "router-key", + idx: 2, + __runestoneCourse: "course-1" + }); + expect(sessionStorage.getItem("assignment-builder.active-course")).toBe("course-1"); + }); + + it("marks the target course before reloading after a switch", () => { + markCourseSwitch("course-2"); + + expect(Object.entries(window.history.state)).toContainEqual(["__runestoneCourse", "course-2"]); + expect(sessionStorage.getItem("assignment-builder.active-course")).toBe("course-2"); + }); + + it.each([ + [ + "/grader/22/questions/24/students/filter_full_credit_student", + "/assignment/instructor/grader" + ], + ["/gradebook", "/assignment/instructor/grader"], + ["/builder/22", "/assignment/instructor/builder"] + ])("reconciles a restored %s page with the server course", (pathname, destination) => { + expect(reconcileServerCourse("course-1", "course-2", pathname)).toBe(destination); + expect(sessionStorage.getItem("assignment-builder.active-course")).toBe("course-2"); + expect(window.history.state[HISTORY_COURSE_KEY]).toBe("course-2"); + }); + + it.each([undefined, "course-1"])( + "does not redirect when the server course is %s", + (serverCourse) => { + expect(reconcileServerCourse("course-1", serverCourse, "/grader/22")).toBeNull(); + } + ); + + it("redirects a stale grader entry to the grader assignment list", () => { + syncCourseHistoryEntry("course-1", "/grader/22"); + + expect(syncCourseHistoryEntry("course-2", "/grader/22")).toBe("/assignment/instructor/grader"); + }); + + it("redirects a stale builder entry to the builder assignment list", () => { + syncCourseHistoryEntry("course-1", "/builder/22"); + + expect(syncCourseHistoryEntry("course-2", "/builder/22")).toBe( + "/assignment/instructor/builder" + ); + }); + + it("redirects an old course page restored from the back-forward cache", () => { + markCourseSwitch("course-2"); + + expect(getRestoredCourseDestination(true, "course-1", "/grader/22")).toBe( + "/assignment/instructor/grader" + ); + expect(getRestoredCourseDestination(false, "course-1", "/grader/22")).toBeNull(); + expect(getRestoredCourseDestination(true, "course-2", "/grader/22")).toBeNull(); + }); + + it("redirects before a restored React effect can overwrite the active course", () => { + syncCourseHistoryEntry("course-1", "/grader/22"); + window.history.pushState({ key: "question-key", idx: 3 }, "", "/grader/22/questions/24"); + markCourseSwitch("course-2"); + window.history.replaceState( + { key: "router-key", idx: 2, __runestoneCourse: "course-1" }, + "", + "/grader/22" + ); + + expect(syncCourseHistoryEntry("course-1", "/grader/22")).toBe("/assignment/instructor/grader"); + expect(sessionStorage.getItem("assignment-builder.active-course")).toBe("course-2"); + expect(window.history.state[HISTORY_COURSE_KEY]).toBe("course-2"); + }); + + it("accepts a course changed outside this dropdown without redirecting forever", () => { + window.history.replaceState({ key: "fresh-entry", idx: 2 }, "", "/grader"); + sessionStorage.setItem("assignment-builder.active-course", "course-2"); + sessionStorage.removeItem("assignment-builder.course-switch-history-index"); + + expect(syncCourseHistoryEntry("course-1", "/grader")).toBeNull(); + expect(sessionStorage.getItem("assignment-builder.active-course")).toBe("course-1"); + }); + + it("keeps Back out of assignment-specific grader pages after switching courses", () => { + syncCourseHistoryEntry("course-1", "/grader/22"); + const assignmentEntry = window.history.state; + + window.history.pushState( + { key: "question-key", idx: 3 }, + "", + "/grader/22/questions/24/students/filter_full_credit_student" + ); + syncCourseHistoryEntry( + "course-1", + "/grader/22/questions/24/students/filter_full_credit_student" + ); + markCourseSwitch("course-2"); + + window.history.replaceState( + { key: assignmentEntry.key, idx: assignmentEntry.idx }, + "", + "/grader/22" + ); + + expect(syncCourseHistoryEntry("course-2", "/grader/22")).toBe("/assignment/instructor/grader"); + }); +}); diff --git a/bases/rsptx/assignment_server_api/assignment_builder/src/courseHistory.ts b/bases/rsptx/assignment_server_api/assignment_builder/src/courseHistory.ts new file mode 100644 index 000000000..f8d18ed35 --- /dev/null +++ b/bases/rsptx/assignment_server_api/assignment_builder/src/courseHistory.ts @@ -0,0 +1,150 @@ +import { getCourseSwitchDestination } from "./navUtils.js"; + +const ACTIVE_COURSE_KEY = "assignment-builder.active-course"; +const SWITCH_HISTORY_INDEX_KEY = "assignment-builder.course-switch-history-index"; +const HISTORY_COURSE_KEY = "__runestoneCourse"; + +const getHistoryState = (): Record => { + const state = window.history.state; + + return typeof state === "object" && state !== null ? state : {}; +}; + +const getStoredCourse = (): string | null => { + try { + return window.sessionStorage.getItem(ACTIVE_COURSE_KEY); + } catch { + return null; + } +}; + +const storeCourse = (courseName: string): void => { + try { + window.sessionStorage.setItem(ACTIVE_COURSE_KEY, courseName); + } catch { + // Course switching must still work when session storage is unavailable. + } +}; + +const getHistoryIndex = (): number | null => { + const index = getHistoryState().idx; + + return typeof index === "number" ? index : null; +}; + +const getSwitchHistoryIndex = (): number | null => { + try { + const value = window.sessionStorage.getItem(SWITCH_HISTORY_INDEX_KEY); + + return value === null ? null : Number(value); + } catch { + return null; + } +}; + +const storeSwitchHistoryIndex = (index: number): void => { + try { + window.sessionStorage.setItem(SWITCH_HISTORY_INDEX_KEY, String(index)); + } catch { + // History stamping remains the fallback when session storage is unavailable. + } +}; + +const isSectionLanding = (pathname: string): boolean => + pathname === "/" || pathname === "/builder" || pathname === "/grader"; + +const isBeforeCourseSwitch = (): boolean => { + const currentIndex = getHistoryIndex(); + const switchIndex = getSwitchHistoryIndex(); + + return currentIndex !== null && switchIndex !== null && currentIndex < switchIndex; +}; + +const stampHistoryEntry = (courseName: string): void => { + window.history.replaceState( + { ...getHistoryState(), [HISTORY_COURSE_KEY]: courseName }, + document.title + ); +}; + +/** Record the target course before the full-page navigation completes. */ +export const markCourseSwitch = (courseName: string): void => { + storeCourse(courseName); + stampHistoryEntry(courseName); + const historyIndex = getHistoryIndex(); + + if (historyIndex !== null) storeSwitchHistoryIndex(historyIndex); +}; + +/** Reconcile a BFCache-restored page with the course currently stored by the server. */ +export const reconcileServerCourse = ( + pageCourse: string, + serverCourse: string | undefined, + pathname: string +): string | null => { + if (!serverCourse || serverCourse === pageCourse) return null; + + markCourseSwitch(serverCourse); + return getCourseSwitchDestination(pathname); +}; + +/** + * Associate the current browser-history entry with its course. A stamped entry + * from another course is stale and must not restore an assignment-specific URL. + */ +export const syncCourseHistoryEntry = (currentCourse: string, pathname: string): string | null => { + const entryCourse = getHistoryState()[HISTORY_COURSE_KEY]; + const activeCourse = getStoredCourse(); + + // A page restored from the back-forward cache can run this effect before its + // pageshow handler. Compare the restored entry with the tab's active course + // before the old React tree can overwrite session storage. + if (activeCourse && typeof entryCourse === "string" && entryCourse !== activeCourse) { + // location.replace preserves the current entry's history state. Stamp the + // target first so the landing page does not inherit stale state and loop. + stampHistoryEntry(activeCourse); + return getCourseSwitchDestination(pathname); + } + + if (typeof entryCourse === "string" && entryCourse !== currentCourse) { + const courseAfterSwitch = getSwitchHistoryIndex() !== null ? activeCourse : null; + + storeCourse(courseAfterSwitch ?? currentCourse); + stampHistoryEntry(courseAfterSwitch ?? currentCourse); + return getCourseSwitchDestination(pathname); + } + + if (isBeforeCourseSwitch()) { + if (!isSectionLanding(pathname)) { + stampHistoryEntry(currentCourse); + return getCourseSwitchDestination(pathname); + } + + const historyIndex = getHistoryIndex(); + + if (historyIndex !== null) storeSwitchHistoryIndex(historyIndex); + } + + storeCourse(currentCourse); + stampHistoryEntry(currentCourse); + + return null; +}; + +/** Detect a page from the old course restored by the browser's back-forward cache. */ +export const getRestoredCourseDestination = ( + persisted: boolean, + pageCourse: string, + pathname: string +): string | null => { + if (!persisted) return null; + + const activeCourse = getStoredCourse(); + const activeCourseMismatch = Boolean(activeCourse && activeCourse !== pageCourse); + const staleNestedEntry = isBeforeCourseSwitch() && !isSectionLanding(pathname); + + if (!activeCourseMismatch && !staleNestedEntry) return null; + + stampHistoryEntry(activeCourseMismatch ? activeCourse! : pageCourse); + return getCourseSwitchDestination(pathname); +}; diff --git a/bases/rsptx/assignment_server_api/assignment_builder/src/navUtils.spec.ts b/bases/rsptx/assignment_server_api/assignment_builder/src/navUtils.spec.ts index b44b49297..8a158ec28 100644 --- a/bases/rsptx/assignment_server_api/assignment_builder/src/navUtils.spec.ts +++ b/bases/rsptx/assignment_server_api/assignment_builder/src/navUtils.spec.ts @@ -1,4 +1,4 @@ -import { buildNavBar, isNavItemActive, NavItem } from "./navUtils"; +import { buildNavBar, getCourseSwitchDestination, isNavItemActive, NavItem } from "./navUtils"; const makeConfig = (overrides: EBookConfig = {}): EBookConfig => ({ isInstructor: true, @@ -71,11 +71,11 @@ describe("buildNavBar", () => { expect(items.length).toBeGreaterThan(0); }); - it("includes Home, Dashboard, Grader, Assignment Builder, User, and Help labels", () => { + it("includes Course, Dashboard, Grader, Assignment Builder, User, and Help labels", () => { const items = buildNavBar(makeConfig()); const topLabels = items.map((i) => i.label); - expect(topLabels).toContain("Home"); + expect(topLabels).toContain("Course: test-course"); expect(topLabels).toContain("Dashboard"); expect(topLabels).toContain("Grader"); expect(topLabels).toContain("Assignment Builder"); @@ -166,14 +166,6 @@ describe("buildNavBar", () => { }); describe("navigation behavior — server paths", () => { - it("sets window.location.href for /runestone paths", () => { - const items = buildNavBar(makeConfig()); - const home = items.find((i) => i.label === "Home"); - - home?.command?.(); - expect(hrefSpy).toHaveBeenCalledWith("/runestone/default/index"); - }); - it("sets window.location.href for /ns paths", () => { const items = buildNavBar(makeConfig({ course: "cs101" })); const back = items.find((i) => i.label?.startsWith("Back to")); @@ -240,11 +232,11 @@ describe("buildNavBar", () => { it("still uses window.location.href for server paths even with navigate fn", () => { const navigate = vi.fn(); - const items = buildNavBar(makeConfig(), navigate); - const home = items.find((i) => i.label === "Home"); + const items = buildNavBar(makeConfig({ course: "cs101" }), navigate); + const back = items.find((i) => i.label?.startsWith("Back to")); - home?.command?.(); - expect(hrefSpy).toHaveBeenCalledWith("/runestone/default/index"); + back?.command?.(); + expect(hrefSpy).toHaveBeenCalledWith("/ns/books/published/cs101/index.html"); expect(navigate).not.toHaveBeenCalled(); }); @@ -271,6 +263,38 @@ describe("buildNavBar", () => { expect(builder?.activePrefixes).toEqual(["/builder", "/"]); }); }); + + describe("course switcher", () => { + it("replaces Home with the current course and recent instructor courses", () => { + const onSwitch = vi.fn(); + const items = buildNavBar(makeConfig({ course: "cs101" }), undefined, { + courses: [ + { course_name: "cs101", is_current: true }, + { course_name: "cs102", is_current: false } + ], + onSwitch + }); + const courseMenu = items.find((item) => item.label === "Course: cs101"); + + expect(items.some((item) => item.label === "Home")).toBe(false); + expect(courseMenu?.items?.map((item) => item.label)).toEqual(["cs101", "cs102"]); + expect(courseMenu?.items?.[0]).toMatchObject({ icon: "check", disabled: true }); + + courseMenu?.items?.[1].command?.(); + expect(onSwitch).toHaveBeenCalledWith("cs102"); + }); + + it("disables course choices while a switch is in progress", () => { + const items = buildNavBar(makeConfig({ course: "cs101" }), undefined, { + courses: [{ course_name: "cs102", is_current: false }], + onSwitch: vi.fn(), + isSwitching: true + }); + const courseMenu = items.find((item) => item.label === "Course: cs101"); + + expect(courseMenu?.items?.every((item) => item.disabled)).toBe(true); + }); + }); }); describe("isNavItemActive", () => { @@ -302,3 +326,19 @@ describe("isNavItemActive", () => { expect(isNavItemActive({ label: "Home" }, "/builder")).toBe(false); }); }); + +describe("getCourseSwitchDestination", () => { + it.each(["/", "/builder", "/builder/123", "/builder/123/exercises/edit/456"])( + "returns the assignment list for builder route %s", + (pathname) => { + expect(getCourseSwitchDestination(pathname)).toBe("/assignment/instructor/builder"); + } + ); + + it.each(["/grader", "/grader/123", "/grader/123/questions/456", "/gradebook"])( + "returns the grader assignment list for grader route %s", + (pathname) => { + expect(getCourseSwitchDestination(pathname)).toBe("/assignment/instructor/grader"); + } + ); +}); diff --git a/bases/rsptx/assignment_server_api/assignment_builder/src/navUtils.ts b/bases/rsptx/assignment_server_api/assignment_builder/src/navUtils.ts index d065aa43b..71d2f041d 100644 --- a/bases/rsptx/assignment_server_api/assignment_builder/src/navUtils.ts +++ b/bases/rsptx/assignment_server_api/assignment_builder/src/navUtils.ts @@ -10,11 +10,28 @@ export interface NavItem { visible?: boolean; separator?: boolean; activePrefixes?: string[]; + disabled?: boolean; +} + +export interface CourseSwitcherOptions { + courses: Array<{ course_name: string; is_current: boolean }>; + onSwitch: (courseName: string) => void; + isSwitching?: boolean; } export const isNavItemActive = (item: NavItem, pathname: string): boolean => !!item.activePrefixes?.some((prefix) => pathname === prefix || pathname.startsWith(`${prefix}/`)); +export const getCourseSwitchDestination = (pathname: string): string => { + const isGraderRoute = + pathname === "/grader" || + pathname.startsWith("/grader/") || + pathname === "/gradebook" || + pathname.startsWith("/gradebook/"); + + return isGraderRoute ? "/assignment/instructor/grader" : "/assignment/instructor/builder"; +}; + const defaultEBookConfig: EBookConfig = { isInstructor: true, course: "dev mode", @@ -47,18 +64,37 @@ const createNavigateToPath = (navigate?: (path: string) => void) => (path: strin } }; -export const buildNavBar = (eBookConfig: EBookConfig, navigate?: (path: string) => void) => { +export const buildNavBar = ( + eBookConfig: EBookConfig, + navigate?: (path: string) => void, + courseSwitcher?: CourseSwitcherOptions +) => { const { isInstructor, course, username } = isEmptyObject(eBookConfig) ? defaultEBookConfig : eBookConfig; const navigateToPath = createNavigateToPath(navigate); + const courseItems: NavItem[] = courseSwitcher + ? courseSwitcher.courses.map((availableCourse) => ({ + label: availableCourse.course_name, + icon: availableCourse.is_current ? "check" : undefined, + command: availableCourse.is_current + ? undefined + : () => courseSwitcher.onSwitch(availableCourse.course_name), + disabled: availableCourse.is_current || courseSwitcher.isSwitching + })) + : []; + + if (courseItems.length === 0) { + courseItems.unshift({ label: course || "Current course", icon: "check", disabled: true }); + } + const items: Array = [ { - label: "Home", - icon: "home", - command: () => navigateToPath("/runestone/default/index") + label: `Course: ${course || "Course"}`, + icon: "book", + items: courseItems }, { label: `Back to ${course || "Course"}`, diff --git a/bases/rsptx/assignment_server_api/assignment_builder/src/state/store.ts b/bases/rsptx/assignment_server_api/assignment_builder/src/state/store.ts index 6baf75426..1bf9548e2 100644 --- a/bases/rsptx/assignment_server_api/assignment_builder/src/state/store.ts +++ b/bases/rsptx/assignment_server_api/assignment_builder/src/state/store.ts @@ -4,6 +4,7 @@ import { assignmentApi } from "@store/assignment/assignment.logic.api.js"; import { assignmentExerciseSlice } from "@store/assignmentExercise/assignmentExercise.logic"; import { assignmentExerciseApi } from "@store/assignmentExercise/assignmentExercise.logic.api"; import { chooseExercisesSlice } from "@store/chooseExercises/chooseExercises.logic"; +import { courseApi } from "@store/course/course.logic.api"; import { datafileApi } from "@store/datafile/datafile.logic.api"; import { datasetSlice } from "@store/dataset/dataset.logic"; import { datasetApi } from "@store/dataset/dataset.logic.api"; @@ -38,6 +39,7 @@ const reducersMap = { student: studentReducer, [assignmentApi.reducerPath]: assignmentApi.reducer, [assignmentExerciseApi.reducerPath]: assignmentExerciseApi.reducer, + [courseApi.reducerPath]: courseApi.reducer, assignmentTemp: assignmentSlice.reducer, user: userSlice.reducer, readings: readingsSlice.reducer, @@ -63,6 +65,7 @@ export const setupStore = (preloadedState?: Partial) => { return getDefaultMiddleware({ serializableCheck: false }).concat( assignmentApi.middleware, assignmentExerciseApi.middleware, + courseApi.middleware, readingsApi.middleware, exercisesApi.middleware, datasetApi.middleware, diff --git a/bases/rsptx/assignment_server_api/assignment_builder/src/store/course/course.logic.api.spec.ts b/bases/rsptx/assignment_server_api/assignment_builder/src/store/course/course.logic.api.spec.ts new file mode 100644 index 000000000..03c96af83 --- /dev/null +++ b/bases/rsptx/assignment_server_api/assignment_builder/src/store/course/course.logic.api.spec.ts @@ -0,0 +1,77 @@ +import { configureStore } from "@reduxjs/toolkit"; + +import { baseQueryWithErrorHandlers } from "@/store/baseQuery"; + +import { courseApi } from "./course.logic.api"; + +vi.mock("@/store/baseQuery", () => ({ + baseQueryWithErrorHandlers: vi.fn() +})); + +const buildStore = () => + configureStore({ + reducer: { [courseApi.reducerPath]: courseApi.reducer }, + middleware: (getDefaultMiddleware) => getDefaultMiddleware().concat(courseApi.middleware) + }); + +describe("courseApi", () => { + beforeEach(() => { + vi.mocked(baseQueryWithErrorHandlers).mockReset(); + }); + + it("loads recent instructor courses", async () => { + vi.mocked(baseQueryWithErrorHandlers).mockResolvedValue({ + data: { + detail: { + courses: [ + { + course_name: "cs101", + last_access: "2026-10-07T12:00:00", + is_current: true + } + ] + } + } + }); + const store = buildStore(); + + const result = await store + .dispatch(courseApi.endpoints.getRecentInstructorCourses.initiate()) + .unwrap(); + + expect(baseQueryWithErrorHandlers).toHaveBeenCalledWith( + expect.objectContaining({ + method: "GET", + url: "/assignment/instructor/courses/recent" + }), + expect.anything(), + undefined + ); + expect(result).toEqual([ + { + course_name: "cs101", + last_access: "2026-10-07T12:00:00", + is_current: true + } + ]); + }); + + it("posts the selected course name", async () => { + vi.mocked(baseQueryWithErrorHandlers).mockResolvedValue({ + data: { detail: { status: "success", course_name: "cs102" } } + }); + const store = buildStore(); + + await store.dispatch(courseApi.endpoints.switchInstructorCourse.initiate("cs102")).unwrap(); + + expect(baseQueryWithErrorHandlers).toHaveBeenCalledWith( + expect.objectContaining({ + method: "POST", + url: "/assignment/instructor/courses/switch", + body: { course_name: "cs102" } + }), + expect.anything(), + undefined + ); + }); +}); diff --git a/bases/rsptx/assignment_server_api/assignment_builder/src/store/course/course.logic.api.ts b/bases/rsptx/assignment_server_api/assignment_builder/src/store/course/course.logic.api.ts new file mode 100644 index 000000000..05a175958 --- /dev/null +++ b/bases/rsptx/assignment_server_api/assignment_builder/src/store/course/course.logic.api.ts @@ -0,0 +1,44 @@ +import { createApi } from "@reduxjs/toolkit/query/react"; + +import { baseQueryWithErrorHandlers } from "@/store/baseQuery"; +import { DetailResponse } from "@/types/api"; + +export interface InstructorCourse { + course_name: string; + last_access: string | null; + is_current: boolean; +} + +export interface RecentInstructorCoursesResponse { + courses: InstructorCourse[]; +} + +export interface SwitchCourseResponse { + status: "success"; + course_name: string; +} + +export const courseApi = createApi({ + reducerPath: "courseApi", + baseQuery: baseQueryWithErrorHandlers, + endpoints: (build) => ({ + getRecentInstructorCourses: build.query({ + query: () => ({ + method: "GET", + url: "/assignment/instructor/courses/recent" + }), + transformResponse: (response: DetailResponse) => + response.detail.courses + }), + switchInstructorCourse: build.mutation({ + query: (courseName) => ({ + method: "POST", + url: "/assignment/instructor/courses/switch", + body: { course_name: courseName } + }), + transformResponse: (response: DetailResponse) => response.detail + }) + }) +}); + +export const { useGetRecentInstructorCoursesQuery, useSwitchInstructorCourseMutation } = courseApi; diff --git a/bases/rsptx/assignment_server_api/assignment_builder/src/store/rootReducer.ts b/bases/rsptx/assignment_server_api/assignment_builder/src/store/rootReducer.ts index 1e9b7ff42..11f72b118 100755 --- a/bases/rsptx/assignment_server_api/assignment_builder/src/store/rootReducer.ts +++ b/bases/rsptx/assignment_server_api/assignment_builder/src/store/rootReducer.ts @@ -4,6 +4,7 @@ import { assignmentApi } from "@store/assignment/assignment.logic.api"; import { assignmentExerciseSlice } from "@store/assignmentExercise/assignmentExercise.logic"; import { assignmentExerciseApi } from "@store/assignmentExercise/assignmentExercise.logic.api"; import { chooseExercisesSlice } from "@store/chooseExercises/chooseExercises.logic"; +import { courseApi } from "@store/course/course.logic.api"; import { datafileApi } from "@store/datafile/datafile.logic.api"; import { datasetSlice } from "@store/dataset/dataset.logic"; import { datasetApi } from "@store/dataset/dataset.logic.api"; @@ -27,6 +28,7 @@ const reducersMap = { assignmentExercise: assignmentExerciseSlice.reducer, [assignmentApi.reducerPath]: assignmentApi.reducer, [assignmentExerciseApi.reducerPath]: assignmentExerciseApi.reducer, + [courseApi.reducerPath]: courseApi.reducer, [readingsApi.reducerPath]: readingsApi.reducer, [exercisesApi.reducerPath]: exercisesApi.reducer, [datasetApi.reducerPath]: datasetApi.reducer, diff --git a/bases/rsptx/assignment_server_api/assignment_builder/src/store/store.ts b/bases/rsptx/assignment_server_api/assignment_builder/src/store/store.ts index 51b696f4d..249d07e6c 100755 --- a/bases/rsptx/assignment_server_api/assignment_builder/src/store/store.ts +++ b/bases/rsptx/assignment_server_api/assignment_builder/src/store/store.ts @@ -1,6 +1,7 @@ import { configureStore } from "@reduxjs/toolkit"; import { assignmentApi } from "@store/assignment/assignment.logic.api"; import { assignmentExerciseApi } from "@store/assignmentExercise/assignmentExercise.logic.api"; +import { courseApi } from "@store/course/course.logic.api"; import { datafileApi } from "@store/datafile/datafile.logic.api"; import { datasetApi } from "@store/dataset/dataset.logic.api"; import { exercisesApi } from "@store/exercises/exercises.logic.api"; @@ -16,6 +17,7 @@ export const setupStore = (preloadedState?: Partial) => { return getDefaultMiddleware({ serializableCheck: false }).concat( assignmentApi.middleware, assignmentExerciseApi.middleware, + courseApi.middleware, readingsApi.middleware, exercisesApi.middleware, datasetApi.middleware, diff --git a/bases/rsptx/assignment_server_api/assignment_builder/src/utils/apiError.ts b/bases/rsptx/assignment_server_api/assignment_builder/src/utils/apiError.ts new file mode 100644 index 000000000..d7e02a983 --- /dev/null +++ b/bases/rsptx/assignment_server_api/assignment_builder/src/utils/apiError.ts @@ -0,0 +1,5 @@ +export const isNotFoundError = (error: unknown): boolean => + typeof error === "object" && + error !== null && + "status" in error && + (error as { status?: unknown }).status === 404; diff --git a/bases/rsptx/assignment_server_api/routers/instructor.py b/bases/rsptx/assignment_server_api/routers/instructor.py index ddc3b5d1e..ecd54d8e7 100644 --- a/bases/rsptx/assignment_server_api/routers/instructor.py +++ b/bases/rsptx/assignment_server_api/routers/instructor.py @@ -46,6 +46,8 @@ upsert_deadline_exception, create_question, fetch_course, + fetch_instructor_courses, + fetch_recent_instructor_courses, fetch_users_for_course, create_code_entry, fetch_subchapters, @@ -64,6 +66,8 @@ fetch_student_assignment_scores, fetch_grade, fetch_user, + record_course_access, + update_user, user_in_course, get_peer_votes, search_exercises, @@ -137,6 +141,54 @@ ) +class SwitchInstructorCourseRequest(BaseModel): + course_name: str + + +@router.get("/courses/recent") +@instructor_role_required() +async def get_recent_instructor_courses(request: Request, user=Depends(auth_manager)): + """Return the ten most recently accessed courses taught by the user.""" + courses = await fetch_recent_instructor_courses(user.id, limit=10) + return make_json_response( + status=status.HTTP_200_OK, + detail={ + "courses": [ + { + **course, + "is_current": course["course_name"] == user.course_name, + } + for course in courses + ] + }, + ) + + +@router.post("/courses/switch") +@instructor_role_required() +async def switch_instructor_course( + request: Request, + payload: SwitchInstructorCourseRequest, + user=Depends(auth_manager), +): + """Make one of the caller's instructor courses their active course.""" + course = await fetch_course(payload.course_name) + if not course or not await fetch_instructor_courses(user.id, course.id): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail="You are not an instructor for that course", + ) + + await update_user( + user.id, {"course_name": course.course_name, "course_id": course.id} + ) + await record_course_access(user.username, course.course_name, canonical_utcnow()) + return make_json_response( + status=status.HTTP_200_OK, + detail={"status": "success", "course_name": course.course_name}, + ) + + @router.get("/reviewPeerAssignment") async def review_peer_assignment( request: Request, diff --git a/bases/rsptx/interactives/runestone/dragndrop/js/dragndrop.js b/bases/rsptx/interactives/runestone/dragndrop/js/dragndrop.js index ebab594c1..9b266b830 100644 --- a/bases/rsptx/interactives/runestone/dragndrop/js/dragndrop.js +++ b/bases/rsptx/interactives/runestone/dragndrop/js/dragndrop.js @@ -495,6 +495,10 @@ export default class DragNDrop extends RunestoneBase { if (info && premise.nextElementSibling !== info) { this.removeCardInfo(premise); } + // A premise returned to the left column is no longer misplaced. + if (premise.parentElement === this.draggableDiv) { + this.clearPremiseIncorrect(premise); + } const premiseLabel = getAccessibleElementText(premise); const response = this.responseArray.includes(premise.parentElement) ? premise.parentElement @@ -1069,9 +1073,7 @@ export default class DragNDrop extends RunestoneBase { } for (let premise of this.premiseArray) { // Clear any incorrect highlighting left over from a previous check - premise.classList.remove("drop-incorrect"); - premise.setAttribute("aria-invalid", "false"); - premise.removeAttribute("aria-errormessage"); + this.clearPremiseIncorrect(premise); this.draggableDiv.appendChild(premise); } this.updatePremiseAriaLabels(); @@ -1211,6 +1213,14 @@ export default class DragNDrop extends RunestoneBase { } await this.logBookEvent(data); } + clearPremiseIncorrect(premise) { + premise.classList.remove("drop-incorrect"); + premise.setAttribute("aria-invalid", "false"); + premise.removeAttribute("aria-errormessage"); + document + .getElementById(premise.id + "_error") + ?.classList.add("visuallyhidden"); + } clearIncorrectHighlights() { // Remove the red "drop-incorrect" highlighting and related a11y // attributes from every placed premise. @@ -1218,9 +1228,7 @@ export default class DragNDrop extends RunestoneBase { for (let premise of Array.from(response.childNodes).filter( this.ivp, )) { - premise.classList.remove("drop-incorrect"); - premise.setAttribute("aria-invalid", "false"); - premise.removeAttribute("aria-errormessage"); + this.clearPremiseIncorrect(premise); } } } @@ -1272,9 +1280,7 @@ export default class DragNDrop extends RunestoneBase { .getElementById(premise.id + "_error") .classList.remove("visuallyhidden"); } else { - premise.classList.remove("drop-incorrect"); - premise.setAttribute("aria-invalid", "false"); - premise.removeAttribute("aria-errormessage"); + this.clearPremiseIncorrect(premise); } } } diff --git a/bases/rsptx/interactives/runestone/dragndrop/test/dragndrop.test.js b/bases/rsptx/interactives/runestone/dragndrop/test/dragndrop.test.js index 8b5517ec5..e051317cc 100644 --- a/bases/rsptx/interactives/runestone/dragndrop/test/dragndrop.test.js +++ b/bases/rsptx/interactives/runestone/dragndrop/test/dragndrop.test.js @@ -309,6 +309,69 @@ describe("feedback rendering", () => { }); }); +describe("returning a misplaced premise", () => { + async function dndWithRedPremise() { + const dnd = await makeDnd(); + place(dnd, "p1", "r2"); + place(dnd, "p2", "r1"); + for (let i = 0; i < 3; i++) { + dnd.submitButton.click(); + await feedbackSettles(); + } + const p1 = dnd.premiseArray.find((p) => p.id === "p1"); + expect(p1.classList.contains("drop-incorrect")).toBe(true); + return { dnd, p1 }; + } + + function expectNotMarkedIncorrect(premise) { + expect(premise.classList.contains("drop-incorrect")).toBe(false); + expect(premise.getAttribute("aria-invalid")).toBe("false"); + expect(premise.hasAttribute("aria-errormessage")).toBe(false); + expect( + document + .getElementById(premise.id + "_error") + .classList.contains("visuallyhidden"), + ).toBe(true); + } + + it("clears its red styling when dropped back in the dragzone", async () => { + const { dnd, p1 } = await dndWithRedPremise(); + const drop = new Event("drop", { bubbles: true, cancelable: true }); + Object.defineProperty(drop, "dataTransfer", { + value: { getData: () => p1.id }, + }); + + dnd.draggableDiv.dispatchEvent(drop); + + expect(p1.parentElement).toBe(dnd.draggableDiv); + expectNotMarkedIncorrect(p1); + }); + + it("clears its red styling when returned with the keyboard", async () => { + const { dnd, p1 } = await dndWithRedPremise(); + + p1.dispatchEvent( + new KeyboardEvent("keydown", { key: "Enter", bubbles: true }), + ); + p1.parentElement.dispatchEvent( + new KeyboardEvent("keydown", { key: "ArrowLeft", bubbles: true }), + ); + + expect(p1.parentElement).toBe(dnd.draggableDiv); + expectNotMarkedIncorrect(p1); + }); + + it("keeps a premise red while it stays in a response", async () => { + const { dnd } = await dndWithRedPremise(); + const p2 = dnd.premiseArray.find((p) => p.id === "p2"); + + dnd.responseArray.find((r) => r.id === "r2").appendChild(p2); + dnd.updatePremiseAriaLabel(p2); + + expect(p2.classList.contains("drop-incorrect")).toBe(true); + }); +}); + describe("reset", () => { it("returns premises to the dragzone and starts the try count over", async () => { const dnd = await makeDnd(); diff --git a/components/rsptx/db/crud/__init__.py b/components/rsptx/db/crud/__init__.py index 88789a247..189b84297 100644 --- a/components/rsptx/db/crud/__init__.py +++ b/components/rsptx/db/crud/__init__.py @@ -89,6 +89,7 @@ fetch_current_instructors_for_course, fetch_instructor_courses, is_course_instructor, + fetch_recent_instructor_courses as fetch_recent_instructor_courses, fetch_users_for_course, fetch_courses_by_institution, institution_match_score, diff --git a/components/rsptx/db/crud/course.py b/components/rsptx/db/crud/course.py index d0a8e39d2..b68926c09 100644 --- a/components/rsptx/db/crud/course.py +++ b/components/rsptx/db/crud/course.py @@ -263,6 +263,39 @@ async def fetch_course_access_for_user(user_id: int) -> Dict[str, datetime.datet return {row.course_name: row.last_access for row in res} +async def fetch_recent_instructor_courses( + instructor_id: int, limit: int = 10 +) -> List[Dict[str, Any]]: + """Return courses taught by ``instructor_id`` in recent-access order. + + ``user_courses.last_access`` is optional because older instructor/course + relationships may not have a matching enrollment row. Those courses are + retained and sorted alphabetically after courses with recorded activity. + """ + most_recent_access = func.max(UserCourse.last_access).label("last_access") + query = ( + select(Courses.course_name, most_recent_access) + .join(CourseInstructor, CourseInstructor.course == Courses.id) + .outerjoin( + UserCourse, + and_( + UserCourse.user_id == instructor_id, + UserCourse.course_id == Courses.id, + ), + ) + .where(CourseInstructor.instructor == instructor_id) + .group_by(Courses.course_name) + .order_by(most_recent_access.desc().nulls_last(), Courses.course_name) + .limit(limit) + ) + async with async_session() as session: + rows = (await session.execute(query)).all() + return [ + {"course_name": row.course_name, "last_access": row.last_access} + for row in rows + ] + + # async def fetch_users_for_course(course_name: str) -> list[AuthUserValidator]: """ diff --git a/test/bases/rsptx/assignment_server_api/test_instructor_routes.py b/test/bases/rsptx/assignment_server_api/test_instructor_routes.py index 75f2d2e66..d446f8a34 100644 --- a/test/bases/rsptx/assignment_server_api/test_instructor_routes.py +++ b/test/bases/rsptx/assignment_server_api/test_instructor_routes.py @@ -12,6 +12,7 @@ import pytest from fastapi.responses import HTMLResponse +from types import SimpleNamespace pytestmark = pytest.mark.asyncio(loop_scope="session") @@ -135,6 +136,95 @@ async def test_course_roster_rejects_non_instructor(auth_student_client): assert resp.status_code in (401, 403) +# --------------------------------------------------------------------------- +# Instructor course switcher +# --------------------------------------------------------------------------- + + +async def test_recent_instructor_courses_marks_current_course(auth_instructor_client): + response = await auth_instructor_client.get("/instructor/courses/recent") + + assert response.status_code == 200 + courses = response.json()["detail"]["courses"] + assert any( + course["course_name"] == "test_course_1" and course["is_current"] + for course in courses + ) + + +async def test_switch_instructor_course_updates_active_course( + auth_instructor_client, monkeypatch +): + updated = {} + recorded = {} + + async def fake_fetch_course(course_name): + return SimpleNamespace(id=22, course_name=course_name) + + async def fake_instructor_courses(user_id, course_id): + return [SimpleNamespace(instructor=user_id, course=course_id)] + + async def fake_update_user(user_id, values): + updated.update(user_id=user_id, values=values) + + async def fake_record_access(username, course_name, timestamp): + recorded.update(username=username, course_name=course_name, timestamp=timestamp) + + monkeypatch.setattr( + "rsptx.assignment_server_api.routers.instructor.fetch_course", + fake_fetch_course, + ) + monkeypatch.setattr( + "rsptx.assignment_server_api.routers.instructor.fetch_instructor_courses", + fake_instructor_courses, + ) + monkeypatch.setattr( + "rsptx.assignment_server_api.routers.instructor.update_user", + fake_update_user, + ) + monkeypatch.setattr( + "rsptx.assignment_server_api.routers.instructor.record_course_access", + fake_record_access, + ) + + response = await auth_instructor_client.post( + "/instructor/courses/switch", json={"course_name": "test_course_2"} + ) + + assert response.status_code == 200 + assert response.json()["detail"] == { + "status": "success", + "course_name": "test_course_2", + } + assert updated["values"] == {"course_name": "test_course_2", "course_id": 22} + assert recorded["course_name"] == "test_course_2" + + +async def test_switch_instructor_course_rejects_course_not_taught( + auth_instructor_client, monkeypatch +): + async def fake_fetch_course(course_name): + return SimpleNamespace(id=22, course_name=course_name) + + async def fake_instructor_courses(user_id, course_id): + return [] + + monkeypatch.setattr( + "rsptx.assignment_server_api.routers.instructor.fetch_course", + fake_fetch_course, + ) + monkeypatch.setattr( + "rsptx.assignment_server_api.routers.instructor.fetch_instructor_courses", + fake_instructor_courses, + ) + + response = await auth_instructor_client.post( + "/instructor/courses/switch", json={"course_name": "not_my_course"} + ) + + assert response.status_code == 403 + + # --------------------------------------------------------------------------- # GET /instructor/assignments/{id}/late_students # ---------------------------------------------------------------------------