From 9d6d6edc3d5b2cf0dd3eb91eb95540f819fcf4c0 Mon Sep 17 00:00:00 2001 From: Andrey Morozov Date: Wed, 28 Oct 2026 18:39:09 +0100 Subject: [PATCH 1/5] f-1587 enable native Ctrl/Cmd-click navigation in header --- .../src/components/shell/AppNavBar.spec.tsx | 100 ++++++++++++++++-- .../src/components/shell/AppNavBar.tsx | 29 +++-- .../assignment_builder/src/navUtils.spec.ts | 18 ++++ .../assignment_builder/src/navUtils.ts | 20 ++++ 4 files changed, 152 insertions(+), 15 deletions(-) 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 5c423fa46..b161b4b4a 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 @@ -1,7 +1,7 @@ import userEvent from "@testing-library/user-event"; import { NavItem } from "@/navUtils"; -import { renderWithMantine, screen, waitFor, within } from "@/test/renderWithMantine"; +import { fireEvent, renderWithMantine, screen, waitFor, within } from "@/test/renderWithMantine"; import { AppNavBar } from "./AppNavBar"; @@ -22,31 +22,66 @@ const makeItems = (overrides: Partial[] = []): NavItem[] => [ ]; describe("AppNavBar", () => { - it("renders top-level item labels as buttons", () => { - renderWithMantine(); + it("renders top-level navigation destinations as links", () => { + const items: NavItem[] = [ + { label: "Grader", href: "/assignment/instructor/grader", command: vi.fn() }, + { label: "Back to Course", href: "/ns/course/index", command: vi.fn() }, + { label: "User", items: [{ label: "Logout", command: vi.fn() }] } + ]; - const home = screen.getByRole("button", { name: /home/i }); - const backToCourse = screen.getByRole("button", { name: /back to course/i }); + renderWithMantine(); + + const grader = screen.getByRole("link", { name: /grader/i }); + const backToCourse = screen.getByRole("link", { name: /back to course/i }); const user = screen.getByRole("button", { name: /user/i }); - expect(home).toBeInTheDocument(); + expect(grader).toHaveAttribute("href", "/assignment/instructor/grader"); expect(backToCourse).toBeInTheDocument(); expect(user).toBeInTheDocument(); - expect(home).not.toHaveAttribute("title"); + expect(grader).not.toHaveAttribute("title"); expect(backToCourse).not.toHaveAttribute("title"); expect(user).not.toHaveAttribute("title"); }); it("invokes the command when a top-level item is clicked", async () => { const command = vi.fn(); - const items: NavItem[] = [{ label: "Grader", icon: "check-square", command }]; + const items: NavItem[] = [ + { + label: "Grader", + icon: "check-square", + href: "/assignment/instructor/grader", + command + } + ]; renderWithMantine(); - await userEvent.click(screen.getByRole("button", { name: /grader/i })); + await userEvent.click(screen.getByRole("link", { name: /grader/i })); expect(command).toHaveBeenCalledTimes(1); }); + it.each(["ctrlKey", "metaKey", "shiftKey"] as const)( + "leaves modified clicks to the browser when %s is held", + (modifier) => { + const command = vi.fn(); + const items: NavItem[] = [ + { + label: "Grader", + href: "/assignment/instructor/grader", + command + } + ]; + + renderWithMantine(); + const link = screen.getByRole("link", { name: /grader/i }); + + link.addEventListener("click", (event) => event.preventDefault()); + fireEvent.click(link, { [modifier]: true }); + + expect(command).not.toHaveBeenCalled(); + } + ); + it("opens a submenu and invokes a sub-item command", async () => { const logout = vi.fn(); const items: NavItem[] = [ @@ -67,6 +102,32 @@ describe("AppNavBar", () => { await waitFor(() => expect(logout).toHaveBeenCalledTimes(1)); }); + it("renders submenu destinations as links and preserves modified clicks", async () => { + const command = vi.fn(); + const items: NavItem[] = [ + { + label: "Help", + items: [ + { + label: "Guide", + href: "https://guide.runestone.academy/", + command + } + ] + } + ]; + + renderWithMantine(); + await userEvent.click(screen.getByRole("button", { name: /help/i })); + const guide = await screen.findByRole("menuitem", { name: /guide/i }); + + expect(guide).toHaveAttribute("href", "https://guide.runestone.academy/"); + guide.addEventListener("click", (event) => event.preventDefault()); + fireEvent.click(guide, { ctrlKey: true }); + + expect(command).not.toHaveBeenCalled(); + }); + it("supports arrow navigation and closes a submenu with Escape", async () => { const items: NavItem[] = [ { @@ -202,4 +263,25 @@ describe("AppNavBar", () => { await userEvent.click(within(menu).getByRole("menuitem", { name: /home/i })); await waitFor(() => expect(home).toHaveBeenCalledTimes(1)); }); + + it("renders compact navigation destinations as links", async () => { + const command = vi.fn(); + const items: NavItem[] = [ + { + label: "Grader", + href: "/assignment/instructor/grader", + command + } + ]; + + renderWithMantine(); + await userEvent.click(screen.getByRole("button", { name: /open navigation menu/i })); + const grader = await screen.findByRole("menuitem", { name: /grader/i }); + + expect(grader).toHaveAttribute("href", "/assignment/instructor/grader"); + grader.addEventListener("click", (event) => event.preventDefault()); + fireEvent.click(grader, { metaKey: true }); + + expect(command).not.toHaveBeenCalled(); + }); }); 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 185a864a9..70b2b0e2e 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 @@ -1,6 +1,6 @@ import { Icon } from "@components/ui/Icon"; import { Button, Menu } from "@mantine/core"; -import { Fragment } from "react"; +import { Fragment, MouseEvent } from "react"; import { isNavItemActive, NavItem } from "@/navUtils"; @@ -14,6 +14,17 @@ interface AppNavBarProps { const menuTransitionProps = { transition: "pop", duration: 180 } as const; +const handleNavClick = (item: NavItem) => (event: MouseEvent) => { + if (event.ctrlKey || event.metaKey || event.shiftKey || event.altKey || event.button !== 0) { + return; + } + + if (item.command) { + event.preventDefault(); + item.command(); + } +}; + const renderSubItem = (item: NavItem, index: number) => { if (item.separator) { return ; @@ -24,9 +35,11 @@ const renderSubItem = (item: NavItem, index: number) => { return ( {item.label} @@ -68,6 +81,8 @@ const renderTopItem = (item: NavItem, index: number, activePath: string) => { return ( @@ -100,10 +115,12 @@ const renderCompactItem = (item: NavItem, index: number, activePath: string) => return ( {item.label} 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 8a158ec28..7d7410f4e 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 @@ -264,6 +264,24 @@ describe("buildNavBar", () => { }); }); + it("provides real destinations for navigation links", () => { + const items = buildNavBar(makeConfig()); + const userItems = items.find((item) => item.label === "User")?.items; + const helpItems = items.find((item) => item.label === "Help")?.items; + + expect(items.find((item) => item.label === "Grader")?.href).toBe( + "/assignment/instructor/grader" + ); + expect(items.find((item) => item.label === "Assignment Builder")?.href).toBe( + "/assignment/instructor/builder" + ); + expect(userItems?.find((item) => item.label === "Course home")?.href).toBe("/ns/course/index"); + expect(helpItems?.find((item) => item.label === "Instructor's guide")?.href).toBe( + "https://guide.runestone.academy/" + ); + expect(userItems?.find((item) => item.label === "Logout")?.href).toBeUndefined(); + }); + describe("course switcher", () => { it("replaces Home with the current course and recent instructor courses", () => { const onSwitch = vi.fn(); 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 71d2f041d..63c6ff091 100644 --- a/bases/rsptx/assignment_server_api/assignment_builder/src/navUtils.ts +++ b/bases/rsptx/assignment_server_api/assignment_builder/src/navUtils.ts @@ -5,6 +5,7 @@ import { isEmptyObject } from "@/utils/object"; export interface NavItem { label?: string; icon?: PrimeIconName; + href?: string; command?: () => void; items?: NavItem[]; visible?: boolean; @@ -98,22 +99,26 @@ export const buildNavBar = ( }, { label: `Back to ${course || "Course"}`, + href: `/ns/books/published/${course}/index.html`, command: () => navigateToPath("/ns/books/published/" + course + "/index.html") }, { label: "Dashboard", icon: "cog", + href: "/admin/instructor/menu", command: () => navigateToPath("/admin/instructor/menu") }, { label: "Grader", icon: "check-square", + href: "/assignment/instructor/grader", command: () => navigateToPath("grader"), activePrefixes: ["/grader"] }, { label: "Assignment Builder", icon: "file-edit", + href: "/assignment/instructor/builder", command: () => navigateToPath("builder"), activePrefixes: ["/builder", "/"] }, @@ -128,25 +133,30 @@ export const buildNavBar = ( { label: "Course home", icon: "home", + href: "/ns/course/index", command: () => navigateToPath("/ns/course/index") }, { label: "Assignments", icon: "pencil", + href: "/assignment/student/chooseAssignment", command: () => navigateToPath("/assignment/student/chooseAssignment") }, { label: "Practice", icon: "palette", + href: "/runestone/assignments/practice", command: () => navigateToPath("/runestone/assignments/practice") }, { label: "Peer instruction (instructor)", + href: "/assignment/peer/instructor", command: () => navigateToPath("/assignment/peer/instructor"), visible: isInstructor }, { label: "Peer instruction (student)", + href: "/assignment/peer/student", command: () => navigateToPath("/assignment/peer/student") }, { @@ -154,6 +164,7 @@ export const buildNavBar = ( }, { label: "Change course", + href: "/admin/auth/my_courses", command: () => navigateToPath("/admin/auth/my_courses") }, { @@ -161,15 +172,18 @@ export const buildNavBar = ( }, { label: "Instructor's dashboard", + href: "/admin/instructor/menu", command: () => navigateToPath("/admin/instructor/menu") }, { label: "Accommodations", icon: "ban", + href: "/assignment/instructor/except", command: () => navigateToPath("except") }, { label: "Progress page", + href: "/assignment/student/studentreport", command: () => navigateToPath("/assignment/student/studentreport") }, { @@ -177,10 +191,12 @@ export const buildNavBar = ( }, { label: "Edit profile", + href: "/admin/auth/profile", command: () => navigateToPath("/admin/auth/profile") }, { label: "Change password", + href: "/runestone/default/user/change_password", command: () => navigateToPath("/runestone/default/user/change_password") }, { @@ -196,11 +212,13 @@ export const buildNavBar = ( { label: "Instructor's guide", icon: "book", + href: "https://guide.runestone.academy/", command: () => navigateToPath("https://guide.runestone.academy/") }, { label: "Video tutorials", icon: "video", + href: "https://www.youtube.com/playlist?list=PLnjfglXW2QQRG6NRsg5mq8V99MM_3j_ZU", command: () => navigateToPath( "https://www.youtube.com/playlist?list=PLnjfglXW2QQRG6NRsg5mq8V99MM_3j_ZU" @@ -209,11 +227,13 @@ export const buildNavBar = ( { label: "Contact us", icon: "envelope", + href: "https://github.com/RunestoneInteractive/rs/issues", command: () => navigateToPath("https://github.com/RunestoneInteractive/rs/issues") }, { label: "Join our Discord", icon: "discord", + href: "https://discord.gg/f3Qmbk9P3U", command: () => navigateToPath("https://discord.gg/f3Qmbk9P3U") } ] From 9f157101ad5ac20898093c373ef59688bcb70865 Mon Sep 17 00:00:00 2001 From: Andrey Morozov Date: Wed, 28 Oct 2026 18:59:21 +0100 Subject: [PATCH 2/5] f-1587 rename command to onClick --- .../src/components/shell/AppNavBar.spec.tsx | 70 +++++++++---------- .../src/components/shell/AppNavBar.tsx | 8 +-- .../assignment_builder/src/navUtils.spec.ts | 32 ++++----- .../assignment_builder/src/navUtils.ts | 44 ++++++------ 4 files changed, 77 insertions(+), 77 deletions(-) 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 b161b4b4a..051564993 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 @@ -6,16 +6,16 @@ import { fireEvent, renderWithMantine, screen, waitFor, within } from "@/test/re import { AppNavBar } from "./AppNavBar"; const makeItems = (overrides: Partial[] = []): NavItem[] => [ - { label: "Home", icon: "home", command: vi.fn() }, - { label: "Back to Course", command: vi.fn() }, + { label: "Home", icon: "home", onClick: vi.fn() }, + { label: "Back to Course", onClick: vi.fn() }, { label: "User", icon: "user", items: [ { label: "Welcome alice", icon: "bolt" }, { separator: true }, - { label: "Logout", command: vi.fn() }, - { label: "Hidden", command: vi.fn(), visible: false } + { label: "Logout", onClick: vi.fn() }, + { label: "Hidden", onClick: vi.fn(), visible: false } ] }, ...(overrides as NavItem[]) @@ -24,9 +24,9 @@ const makeItems = (overrides: Partial[] = []): NavItem[] => [ describe("AppNavBar", () => { it("renders top-level navigation destinations as links", () => { const items: NavItem[] = [ - { label: "Grader", href: "/assignment/instructor/grader", command: vi.fn() }, - { label: "Back to Course", href: "/ns/course/index", command: vi.fn() }, - { label: "User", items: [{ label: "Logout", command: vi.fn() }] } + { label: "Grader", href: "/assignment/instructor/grader", onClick: vi.fn() }, + { label: "Back to Course", href: "/ns/course/index", onClick: vi.fn() }, + { label: "User", items: [{ label: "Logout", onClick: vi.fn() }] } ]; renderWithMantine(); @@ -43,32 +43,32 @@ describe("AppNavBar", () => { expect(user).not.toHaveAttribute("title"); }); - it("invokes the command when a top-level item is clicked", async () => { - const command = vi.fn(); + it("invokes onClick when a top-level item is clicked", async () => { + const onClick = vi.fn(); const items: NavItem[] = [ { label: "Grader", icon: "check-square", href: "/assignment/instructor/grader", - command + onClick } ]; renderWithMantine(); await userEvent.click(screen.getByRole("link", { name: /grader/i })); - expect(command).toHaveBeenCalledTimes(1); + expect(onClick).toHaveBeenCalledTimes(1); }); it.each(["ctrlKey", "metaKey", "shiftKey"] as const)( "leaves modified clicks to the browser when %s is held", (modifier) => { - const command = vi.fn(); + const onClick = vi.fn(); const items: NavItem[] = [ { label: "Grader", href: "/assignment/instructor/grader", - command + onClick } ]; @@ -78,17 +78,17 @@ describe("AppNavBar", () => { link.addEventListener("click", (event) => event.preventDefault()); fireEvent.click(link, { [modifier]: true }); - expect(command).not.toHaveBeenCalled(); + expect(onClick).not.toHaveBeenCalled(); } ); - it("opens a submenu and invokes a sub-item command", async () => { + it("opens a submenu and invokes the sub-item onClick callback", async () => { const logout = vi.fn(); const items: NavItem[] = [ { label: "User", icon: "user", - items: [{ label: "Logout", command: logout }] + items: [{ label: "Logout", onClick: logout }] } ]; @@ -103,7 +103,7 @@ describe("AppNavBar", () => { }); it("renders submenu destinations as links and preserves modified clicks", async () => { - const command = vi.fn(); + const onClick = vi.fn(); const items: NavItem[] = [ { label: "Help", @@ -111,7 +111,7 @@ describe("AppNavBar", () => { { label: "Guide", href: "https://guide.runestone.academy/", - command + onClick } ] } @@ -125,7 +125,7 @@ describe("AppNavBar", () => { guide.addEventListener("click", (event) => event.preventDefault()); fireEvent.click(guide, { ctrlKey: true }); - expect(command).not.toHaveBeenCalled(); + expect(onClick).not.toHaveBeenCalled(); }); it("supports arrow navigation and closes a submenu with Escape", async () => { @@ -133,8 +133,8 @@ describe("AppNavBar", () => { { label: "Help", items: [ - { label: "Guide", command: vi.fn() }, - { label: "Contact", command: vi.fn() } + { label: "Guide", onClick: vi.fn() }, + { label: "Contact", onClick: vi.fn() } ] } ]; @@ -168,8 +168,8 @@ describe("AppNavBar", () => { it("hides top-level items whose visible flag is false", () => { const items: NavItem[] = [ - { label: "Shown", command: vi.fn() }, - { label: "Gone", command: vi.fn(), visible: false } + { label: "Shown", onClick: vi.fn() }, + { label: "Gone", onClick: vi.fn(), visible: false } ]; renderWithMantine(); @@ -179,11 +179,11 @@ describe("AppNavBar", () => { }); it("does not invoke disabled course items", async () => { - const command = vi.fn(); + const onClick = vi.fn(); const items: NavItem[] = [ { label: "Course: cs101", - items: [{ label: "cs102", command, disabled: true }] + items: [{ label: "cs102", onClick, disabled: true }] } ]; @@ -191,7 +191,7 @@ describe("AppNavBar", () => { await userEvent.click(screen.getByRole("button", { name: /course: cs101/i })); await userEvent.click(await screen.findByRole("menuitem", { name: /cs102/i })); - expect(command).not.toHaveBeenCalled(); + expect(onClick).not.toHaveBeenCalled(); }); it("renders the Runestone logo image alongside the wordmark", () => { @@ -207,8 +207,8 @@ describe("AppNavBar", () => { it("marks the item matching the active path with aria-current", () => { const items: NavItem[] = [ - { label: "Grader", command: vi.fn(), activePrefixes: ["/grader"] }, - { label: "Builder", command: vi.fn(), activePrefixes: ["/builder", "/"] } + { label: "Grader", onClick: vi.fn(), activePrefixes: ["/grader"] }, + { label: "Builder", onClick: vi.fn(), activePrefixes: ["/builder", "/"] } ]; renderWithMantine(); @@ -218,7 +218,7 @@ describe("AppNavBar", () => { }); it("marks no item active when the path matches nothing", () => { - const items: NavItem[] = [{ label: "Grader", command: vi.fn(), activePrefixes: ["/grader"] }]; + const items: NavItem[] = [{ label: "Grader", onClick: vi.fn(), activePrefixes: ["/grader"] }]; renderWithMantine(); @@ -240,13 +240,13 @@ describe("AppNavBar", () => { const home = vi.fn(); const logout = vi.fn(); const items: NavItem[] = [ - { label: "Home", icon: "home", command: home }, + { label: "Home", icon: "home", onClick: home }, { label: "User", icon: "user", items: [ - { label: "Logout", command: logout }, - { label: "Hidden", command: vi.fn(), visible: false } + { label: "Logout", onClick: logout }, + { label: "Hidden", onClick: vi.fn(), visible: false } ] } ]; @@ -265,12 +265,12 @@ describe("AppNavBar", () => { }); it("renders compact navigation destinations as links", async () => { - const command = vi.fn(); + const onClick = vi.fn(); const items: NavItem[] = [ { label: "Grader", href: "/assignment/instructor/grader", - command + onClick } ]; @@ -282,6 +282,6 @@ describe("AppNavBar", () => { grader.addEventListener("click", (event) => event.preventDefault()); fireEvent.click(grader, { metaKey: true }); - expect(command).not.toHaveBeenCalled(); + expect(onClick).not.toHaveBeenCalled(); }); }); 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 70b2b0e2e..c6a1b9b1d 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 @@ -19,9 +19,9 @@ const handleNavClick = (item: NavItem) => (event: MouseEvent) => { return; } - if (item.command) { + if (item.onClick) { event.preventDefault(); - item.command(); + item.onClick(); } }; @@ -38,7 +38,7 @@ const renderSubItem = (item: NavItem, index: number) => { component={item.href ? "a" : "button"} href={item.href} leftSection={leftSection} - disabled={item.disabled || (!item.command && !item.href)} + disabled={item.disabled || (!item.onClick && !item.href)} onClick={handleNavClick(item)} > {item.label} @@ -119,7 +119,7 @@ const renderCompactItem = (item: NavItem, index: number, activePath: string) => href={item.href} className={active ? styles.menuItemActive : undefined} leftSection={leftSection} - disabled={item.disabled || (!item.command && !item.href)} + disabled={item.disabled || (!item.onClick && !item.href)} onClick={handleNavClick(item)} aria-current={active ? "page" : undefined} > 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 7d7410f4e..6c5fc5dbf 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 @@ -147,20 +147,20 @@ describe("buildNavBar", () => { it("opens http URLs in a new tab", () => { const items = buildNavBar(makeConfig()); const helpMenu = items.find((i) => i.label === "Help"); - const subItems = (helpMenu?.items as Array<{ label?: string; command?: () => void }>) ?? []; + const subItems = (helpMenu?.items as Array<{ label?: string; onClick?: () => void }>) ?? []; const guide = subItems.find((s) => s.label === "Instructor's guide"); - guide?.command?.(); + guide?.onClick?.(); expect(openSpy).toHaveBeenCalledWith("https://guide.runestone.academy/", "_blank"); }); it("opens https URLs in a new tab", () => { const items = buildNavBar(makeConfig()); const helpMenu = items.find((i) => i.label === "Help"); - const subItems = (helpMenu?.items as Array<{ label?: string; command?: () => void }>) ?? []; + const subItems = (helpMenu?.items as Array<{ label?: string; onClick?: () => void }>) ?? []; const discord = subItems.find((s) => s.label === "Join our Discord"); - discord?.command?.(); + discord?.onClick?.(); expect(openSpy).toHaveBeenCalledWith("https://discord.gg/f3Qmbk9P3U", "_blank"); }); }); @@ -170,17 +170,17 @@ describe("buildNavBar", () => { const items = buildNavBar(makeConfig({ course: "cs101" })); const back = items.find((i) => i.label?.startsWith("Back to")); - back?.command?.(); + back?.onClick?.(); expect(hrefSpy).toHaveBeenCalledWith("/ns/books/published/cs101/index.html"); }); it("sets window.location.href for /assignment paths", () => { const items = buildNavBar(makeConfig()); const userMenu = items.find((i) => i.label === "User"); - const subItems = (userMenu?.items as Array<{ label?: string; command?: () => void }>) ?? []; + const subItems = (userMenu?.items as Array<{ label?: string; onClick?: () => void }>) ?? []; const assignments = subItems.find((s) => s.label === "Assignments"); - assignments?.command?.(); + assignments?.onClick?.(); expect(hrefSpy).toHaveBeenCalledWith("/assignment/student/chooseAssignment"); }); @@ -188,7 +188,7 @@ describe("buildNavBar", () => { const items = buildNavBar(makeConfig()); const dashboard = items.find((i) => i.label === "Dashboard"); - dashboard?.command?.(); + dashboard?.onClick?.(); expect(hrefSpy).toHaveBeenCalledWith("/admin/instructor/menu"); }); }); @@ -198,7 +198,7 @@ describe("buildNavBar", () => { const items = buildNavBar(makeConfig()); const grader = items.find((i) => i.label === "Grader"); - grader?.command?.(); + grader?.onClick?.(); expect(hashSpy).toHaveBeenCalledWith("#/grader"); }); @@ -206,7 +206,7 @@ describe("buildNavBar", () => { const items = buildNavBar(makeConfig()); const builder = items.find((i) => i.label === "Assignment Builder"); - builder?.command?.(); + builder?.onClick?.(); expect(hashSpy).toHaveBeenCalledWith("#/builder"); }); }); @@ -217,7 +217,7 @@ describe("buildNavBar", () => { const items = buildNavBar(makeConfig(), navigate); const grader = items.find((i) => i.label === "Grader"); - grader?.command?.(); + grader?.onClick?.(); expect(navigate).toHaveBeenCalledWith("/grader"); }); @@ -226,7 +226,7 @@ describe("buildNavBar", () => { const items = buildNavBar(makeConfig(), navigate); const builder = items.find((i) => i.label === "Assignment Builder"); - builder?.command?.(); + builder?.onClick?.(); expect(navigate).toHaveBeenCalledWith("/builder"); }); @@ -235,7 +235,7 @@ describe("buildNavBar", () => { const items = buildNavBar(makeConfig({ course: "cs101" }), navigate); const back = items.find((i) => i.label?.startsWith("Back to")); - back?.command?.(); + back?.onClick?.(); expect(hrefSpy).toHaveBeenCalledWith("/ns/books/published/cs101/index.html"); expect(navigate).not.toHaveBeenCalled(); }); @@ -244,10 +244,10 @@ describe("buildNavBar", () => { const navigate = vi.fn(); const items = buildNavBar(makeConfig(), navigate); const helpMenu = items.find((i) => i.label === "Help"); - const subItems = (helpMenu?.items as Array<{ label?: string; command?: () => void }>) ?? []; + const subItems = (helpMenu?.items as Array<{ label?: string; onClick?: () => void }>) ?? []; const guide = subItems.find((s) => s.label === "Instructor's guide"); - guide?.command?.(); + guide?.onClick?.(); expect(openSpy).toHaveBeenCalledWith("https://guide.runestone.academy/", "_blank"); expect(navigate).not.toHaveBeenCalled(); }); @@ -298,7 +298,7 @@ describe("buildNavBar", () => { expect(courseMenu?.items?.map((item) => item.label)).toEqual(["cs101", "cs102"]); expect(courseMenu?.items?.[0]).toMatchObject({ icon: "check", disabled: true }); - courseMenu?.items?.[1].command?.(); + courseMenu?.items?.[1].onClick?.(); expect(onSwitch).toHaveBeenCalledWith("cs102"); }); 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 63c6ff091..1ec4ae258 100644 --- a/bases/rsptx/assignment_server_api/assignment_builder/src/navUtils.ts +++ b/bases/rsptx/assignment_server_api/assignment_builder/src/navUtils.ts @@ -6,7 +6,7 @@ export interface NavItem { label?: string; icon?: PrimeIconName; href?: string; - command?: () => void; + onClick?: () => void; items?: NavItem[]; visible?: boolean; separator?: boolean; @@ -80,7 +80,7 @@ export const buildNavBar = ( ? courseSwitcher.courses.map((availableCourse) => ({ label: availableCourse.course_name, icon: availableCourse.is_current ? "check" : undefined, - command: availableCourse.is_current + onClick: availableCourse.is_current ? undefined : () => courseSwitcher.onSwitch(availableCourse.course_name), disabled: availableCourse.is_current || courseSwitcher.isSwitching @@ -100,26 +100,26 @@ export const buildNavBar = ( { label: `Back to ${course || "Course"}`, href: `/ns/books/published/${course}/index.html`, - command: () => navigateToPath("/ns/books/published/" + course + "/index.html") + onClick: () => navigateToPath("/ns/books/published/" + course + "/index.html") }, { label: "Dashboard", icon: "cog", href: "/admin/instructor/menu", - command: () => navigateToPath("/admin/instructor/menu") + onClick: () => navigateToPath("/admin/instructor/menu") }, { label: "Grader", icon: "check-square", href: "/assignment/instructor/grader", - command: () => navigateToPath("grader"), + onClick: () => navigateToPath("grader"), activePrefixes: ["/grader"] }, { label: "Assignment Builder", icon: "file-edit", href: "/assignment/instructor/builder", - command: () => navigateToPath("builder"), + onClick: () => navigateToPath("builder"), activePrefixes: ["/builder", "/"] }, { @@ -134,30 +134,30 @@ export const buildNavBar = ( label: "Course home", icon: "home", href: "/ns/course/index", - command: () => navigateToPath("/ns/course/index") + onClick: () => navigateToPath("/ns/course/index") }, { label: "Assignments", icon: "pencil", href: "/assignment/student/chooseAssignment", - command: () => navigateToPath("/assignment/student/chooseAssignment") + onClick: () => navigateToPath("/assignment/student/chooseAssignment") }, { label: "Practice", icon: "palette", href: "/runestone/assignments/practice", - command: () => navigateToPath("/runestone/assignments/practice") + onClick: () => navigateToPath("/runestone/assignments/practice") }, { label: "Peer instruction (instructor)", href: "/assignment/peer/instructor", - command: () => navigateToPath("/assignment/peer/instructor"), + onClick: () => navigateToPath("/assignment/peer/instructor"), visible: isInstructor }, { label: "Peer instruction (student)", href: "/assignment/peer/student", - command: () => navigateToPath("/assignment/peer/student") + onClick: () => navigateToPath("/assignment/peer/student") }, { separator: true @@ -165,7 +165,7 @@ export const buildNavBar = ( { label: "Change course", href: "/admin/auth/my_courses", - command: () => navigateToPath("/admin/auth/my_courses") + onClick: () => navigateToPath("/admin/auth/my_courses") }, { separator: true @@ -173,18 +173,18 @@ export const buildNavBar = ( { label: "Instructor's dashboard", href: "/admin/instructor/menu", - command: () => navigateToPath("/admin/instructor/menu") + onClick: () => navigateToPath("/admin/instructor/menu") }, { label: "Accommodations", icon: "ban", href: "/assignment/instructor/except", - command: () => navigateToPath("except") + onClick: () => navigateToPath("except") }, { label: "Progress page", href: "/assignment/student/studentreport", - command: () => navigateToPath("/assignment/student/studentreport") + onClick: () => navigateToPath("/assignment/student/studentreport") }, { separator: true @@ -192,16 +192,16 @@ export const buildNavBar = ( { label: "Edit profile", href: "/admin/auth/profile", - command: () => navigateToPath("/admin/auth/profile") + onClick: () => navigateToPath("/admin/auth/profile") }, { label: "Change password", href: "/runestone/default/user/change_password", - command: () => navigateToPath("/runestone/default/user/change_password") + onClick: () => navigateToPath("/runestone/default/user/change_password") }, { label: "Logout", - command: () => navigateToPath("/admin/auth/logout") + onClick: () => navigateToPath("/admin/auth/logout") } ] }, @@ -213,13 +213,13 @@ export const buildNavBar = ( label: "Instructor's guide", icon: "book", href: "https://guide.runestone.academy/", - command: () => navigateToPath("https://guide.runestone.academy/") + onClick: () => navigateToPath("https://guide.runestone.academy/") }, { label: "Video tutorials", icon: "video", href: "https://www.youtube.com/playlist?list=PLnjfglXW2QQRG6NRsg5mq8V99MM_3j_ZU", - command: () => + onClick: () => navigateToPath( "https://www.youtube.com/playlist?list=PLnjfglXW2QQRG6NRsg5mq8V99MM_3j_ZU" ) @@ -228,13 +228,13 @@ export const buildNavBar = ( label: "Contact us", icon: "envelope", href: "https://github.com/RunestoneInteractive/rs/issues", - command: () => navigateToPath("https://github.com/RunestoneInteractive/rs/issues") + onClick: () => navigateToPath("https://github.com/RunestoneInteractive/rs/issues") }, { label: "Join our Discord", icon: "discord", href: "https://discord.gg/f3Qmbk9P3U", - command: () => navigateToPath("https://discord.gg/f3Qmbk9P3U") + onClick: () => navigateToPath("https://discord.gg/f3Qmbk9P3U") } ] } From a1d372366c14e5ee612e19bc99efb863a3765eea Mon Sep 17 00:00:00 2001 From: Andrey Morozov Date: Wed, 28 Oct 2026 20:25:38 +0100 Subject: [PATCH 3/5] optimize tests --- .../assignment_server_api/assignment_builder/vite.config.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/bases/rsptx/assignment_server_api/assignment_builder/vite.config.ts b/bases/rsptx/assignment_server_api/assignment_builder/vite.config.ts index bad4fcba3..d51afc4ec 100644 --- a/bases/rsptx/assignment_server_api/assignment_builder/vite.config.ts +++ b/bases/rsptx/assignment_server_api/assignment_builder/vite.config.ts @@ -56,7 +56,8 @@ export default defineConfig(({ mode }: { mode: string }) => { }, plugins: [react()], optimizeDeps: { - include: ["react", "react-dom"] + include: ["react", "react-dom", "tiptap-extension-code-block-prism"], + needsInterop: ["tiptap-extension-code-block-prism"] } }; }); From 2dc29fc6a771fe6b3cb1c4b8d42e84658ae72c5a Mon Sep 17 00:00:00 2001 From: Andrey Morozov Date: Thu, 29 Oct 2026 08:18:15 +0100 Subject: [PATCH 4/5] f-1587 fix build --- .../assignment_builder/package-lock.json | 8 ++++---- .../assignment_server_api/assignment_builder/package.json | 2 +- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/bases/rsptx/assignment_server_api/assignment_builder/package-lock.json b/bases/rsptx/assignment_server_api/assignment_builder/package-lock.json index c4621c027..372c3f447 100644 --- a/bases/rsptx/assignment_server_api/assignment_builder/package-lock.json +++ b/bases/rsptx/assignment_server_api/assignment_builder/package-lock.json @@ -68,7 +68,7 @@ "reselect": "^5.1.1", "screenfull": "^6.0.2", "tippy.js": "^6.3.7", - "tiptap-extension-code-block-prism": "^0.0.2-e", + "tiptap-extension-code-block-prism": "0.0.2-e", "typesafe-actions": "^5.1.0", "web-vitals": "^2.1.4" }, @@ -12656,9 +12656,9 @@ } }, "node_modules/tiptap-extension-code-block-prism": { - "version": "0.0.2", - "resolved": "https://registry.npmjs.org/tiptap-extension-code-block-prism/-/tiptap-extension-code-block-prism-0.0.2.tgz", - "integrity": "sha512-Rt5Q5G4qLMTWJ33jD9Otrkw9u0pu0pZYL3YIGe4AwQovxZe7RgKPaV5bopsX3JSTDuxDzyUjGGmEgR6US2A5Vg==", + "version": "0.0.2-e", + "resolved": "https://registry.npmjs.org/tiptap-extension-code-block-prism/-/tiptap-extension-code-block-prism-0.0.2-e.tgz", + "integrity": "sha512-zMeOJ+qk3r7qqpC0s1i4IM1Eax1WqQmNe9oYzLUHyWXmtaXAcc8FYOizdqJJdZallfoCeT2TuFQfR+Ulxzok5A==", "license": "MIT", "dependencies": { "hast-util-from-html": "1.0.0", diff --git a/bases/rsptx/assignment_server_api/assignment_builder/package.json b/bases/rsptx/assignment_server_api/assignment_builder/package.json index 1c822aa06..fbfefe280 100644 --- a/bases/rsptx/assignment_server_api/assignment_builder/package.json +++ b/bases/rsptx/assignment_server_api/assignment_builder/package.json @@ -68,7 +68,7 @@ "reselect": "^5.1.1", "screenfull": "^6.0.2", "tippy.js": "^6.3.7", - "tiptap-extension-code-block-prism": "^0.0.2-e", + "tiptap-extension-code-block-prism": "0.0.2-e", "typesafe-actions": "^5.1.0", "web-vitals": "^2.1.4" }, From 99a99ccf662e8cd90bcfedce7bda30818c952a72 Mon Sep 17 00:00:00 2001 From: Andrey Morozov Date: Thu, 29 Oct 2026 08:41:04 +0100 Subject: [PATCH 5/5] f-1587 fix build --- .../assignment_builder/package-lock.json | 48 ++++++------------- .../assignment_builder/package.json | 2 +- 2 files changed, 16 insertions(+), 34 deletions(-) diff --git a/bases/rsptx/assignment_server_api/assignment_builder/package-lock.json b/bases/rsptx/assignment_server_api/assignment_builder/package-lock.json index 372c3f447..1fb62c068 100644 --- a/bases/rsptx/assignment_server_api/assignment_builder/package-lock.json +++ b/bases/rsptx/assignment_server_api/assignment_builder/package-lock.json @@ -74,7 +74,7 @@ }, "devDependencies": { "@axe-core/playwright": "^4.11.3", - "@playwright/test": "^1.60.0", + "@playwright/test": "^1.64.1", "@testing-library/jest-dom": "^6.6.3", "@testing-library/react": "^16.0.1", "@testing-library/user-event": "^14.5.2", @@ -3394,19 +3394,19 @@ } }, "node_modules/@playwright/test": { - "version": "1.60.0", - "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.60.0.tgz", - "integrity": "sha512-O71yZIbAh/PxDMNGns37GHBIfrVkEVyn+AXyIa5dOTfb4/xNvRWV+Vv/NMbNCtODB/pO7vLlF2OTmMVLhmr7Ag==", + "version": "1.64.1", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.64.1.tgz", + "integrity": "sha512-IaYGml/nSOiZWROfPtuIL7neuu9qDOZ3TNvNljUIrhDMxiGTgPjzj/g9p9RxpS2tSepPDRY+UwAaEeiKtUvIAg==", "dev": true, "license": "Apache-2.0", "dependencies": { - "playwright": "1.60.0" + "playwright": "1.64.1" }, "bin": { "playwright": "cli.js" }, "engines": { - "node": ">=18" + "node": ">=20" } }, "node_modules/@popperjs/core": { @@ -10419,50 +10419,32 @@ } }, "node_modules/playwright": { - "version": "1.60.0", - "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.60.0.tgz", - "integrity": "sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==", + "version": "1.64.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.64.1.tgz", + "integrity": "sha512-HCrVhHr5QQuwYoswEUGTb7gGp522yflbv/ST8nF4sFWGuLjQCt17Q/EubpGGSNKYn1tsTCPZxqHhHCfVURu7UA==", "dev": true, "license": "Apache-2.0", "dependencies": { - "playwright-core": "1.60.0" + "playwright-core": "1.64.1" }, "bin": { "playwright": "cli.js" }, "engines": { - "node": ">=18" - }, - "optionalDependencies": { - "fsevents": "2.3.2" + "node": ">=20" } }, "node_modules/playwright-core": { - "version": "1.60.0", - "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.60.0.tgz", - "integrity": "sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==", + "version": "1.64.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.64.1.tgz", + "integrity": "sha512-BC4s8y6SEVA6Q1rSxZTto8hI1KUI3xXlHGZgvGqP4IqPdI/a6FX9H29IDNz7vKo9l21b6LfYosScPL6M3xxyDQ==", "dev": true, "license": "Apache-2.0", "bin": { "playwright-core": "cli.js" }, "engines": { - "node": ">=18" - } - }, - "node_modules/playwright/node_modules/fsevents": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", - "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + "node": ">=20" } }, "node_modules/possible-typed-array-names": { diff --git a/bases/rsptx/assignment_server_api/assignment_builder/package.json b/bases/rsptx/assignment_server_api/assignment_builder/package.json index fbfefe280..67b8ff876 100644 --- a/bases/rsptx/assignment_server_api/assignment_builder/package.json +++ b/bases/rsptx/assignment_server_api/assignment_builder/package.json @@ -105,7 +105,7 @@ }, "devDependencies": { "@axe-core/playwright": "^4.11.3", - "@playwright/test": "^1.60.0", + "@playwright/test": "^1.64.1", "@testing-library/jest-dom": "^6.6.3", "@testing-library/react": "^16.0.1", "@testing-library/user-event": "^14.5.2",