From 6f4f250f7b2a003877fc03a6e45b38846f457300 Mon Sep 17 00:00:00 2001 From: Jaycie Brown Date: Wed, 22 Apr 2026 17:30:15 +0100 Subject: [PATCH 01/28] Prevent decks automatically saving to My QDecks on attempt --- src/app/services/questions.ts | 8 -------- 1 file changed, 8 deletions(-) diff --git a/src/app/services/questions.ts b/src/app/services/questions.ts index a4a1e8d8c2..d9f87e2712 100644 --- a/src/app/services/questions.ts +++ b/src/app/services/questions.ts @@ -231,14 +231,6 @@ export const submitCurrentAttempt = (questionPart: AppQuestionDTO | undefined, d const attempt = dispatch(attemptQuestion(docId, questionPart?.currentAttempt, questionType, currentGameboard?.id, inlineContext)); - if (isLoggedIn(currentUser) && !isTeacherPending(currentUser) && currentGameboard?.id && !currentGameboard.savedToCurrentUser) { - dispatch(saveGameboard({ - boardId: currentGameboard.id, - user: currentUser, - redirectOnSuccess: false - })); - } - return attempt; } return Promise.resolve(); From 4177461a629fa380ca438465ba104587d5baec41 Mon Sep 17 00:00:00 2001 From: Jaycie Brown Date: Wed, 22 Apr 2026 17:36:14 +0100 Subject: [PATCH 02/28] Move assignment-setting hooks etc inside cards This allows us to set an assignment from anywhere a card is displayed, rather than requiring a redirect to the set assignments page --- src/app/components/elements/Gameboards.tsx | 2 + .../components/elements/cards/BoardCard.tsx | 28 ++++---- .../elements/cards/GameboardCard.tsx | 43 +++++------- src/app/components/pages/SetAssignments.tsx | 65 ++++++------------- src/app/services/setAssignment.ts | 31 +++++++++ 5 files changed, 86 insertions(+), 83 deletions(-) create mode 100644 src/app/services/setAssignment.ts diff --git a/src/app/components/elements/Gameboards.tsx b/src/app/components/elements/Gameboards.tsx index 91a40bc51e..1c4466766b 100644 --- a/src/app/components/elements/Gameboards.tsx +++ b/src/app/components/elements/Gameboards.tsx @@ -115,6 +115,7 @@ const CSTable = (props: GameboardsTableProps) => { boardView={boardView} user={user} boards={boards} + displayAssignmentInfo={false} />) } @@ -144,6 +145,7 @@ const Cards = (props: GameboardsCardsProps) => { boardView={boardView} user={user} boards={boards} + displayAssignmentInfo={false} /> )} } diff --git a/src/app/components/elements/cards/BoardCard.tsx b/src/app/components/elements/cards/BoardCard.tsx index 3dfac0c40b..0750d9eefd 100644 --- a/src/app/components/elements/cards/BoardCard.tsx +++ b/src/app/components/elements/cards/BoardCard.tsx @@ -39,6 +39,7 @@ import indexOf from "lodash/indexOf"; import { GameboardCard, GameboardLinkLocation } from "./GameboardCard"; import { IconButton } from "../AffixButton"; import { SupersededDeprecatedBoardContentWarning } from "../../navigation/SupersededDeprecatedWarning"; +import { useSetAssignment } from "../../../services/setAssignment"; interface HexagonGroupsButtonProps { @@ -128,24 +129,24 @@ type BoardCardProps = { board: GameboardDTO; boards?: Boards | null; boardView: BoardViews; - // Set assignments only - assignees?: BoardAssignee[]; - toggleAssignModal?: () => void; + displayAssignmentInfo: boolean; // My gameboards only setSelectedBoards?: (selectedBoards: GameboardDTO[]) => void; selectedBoards?: GameboardDTO[]; }; -export const BoardCard = ({user, board, boardView, assignees, toggleAssignModal, setSelectedBoards, selectedBoards}: BoardCardProps) => { +export const BoardCard = ({user, board, boardView, displayAssignmentInfo, setSelectedBoards, selectedBoards}: BoardCardProps) => { // Decides whether we show the "Assign/Unassign" button, along with other "Set Assignments"-specific stuff - const isSetAssignments = isDefined(toggleAssignModal) && isDefined(assignees); + const isSetAssignments = displayAssignmentInfo; const hexagonId = (`board-hex-${board.id}`).replace(/[^a-z0-9-]+/gi, ''); const boardLink = isSetAssignments ? `/assignment/${board.id}` : `${PATHS.GAMEBOARD}#${board.id}`; - const hasAssignedGroups = assignees && assignees.length > 0; - const dispatch = useAppDispatch(); + const { openAssignModal, assignees } = useSetAssignment(board); + const hasAssignedGroups = isDefined(assignees?.length) && assignees.length > 0; + + const dispatch = useAppDispatch(); const deviceSize = useDeviceSize(); const updateBoardSelection = (board: GameboardDTO, checked: boolean) => { @@ -183,7 +184,6 @@ export const BoardCard = ({user, board, boardView, assignees, toggleAssignModal, hexagonId, boardSubjects, assignees, - toggleAssignModal, isTable, }; @@ -227,7 +227,7 @@ export const BoardCard = ({user, board, boardView, assignees, toggleAssignModal, {isAda && {formatBoardOwner(user, board)}} {formatDate(board.lastVisited)} - @@ -285,8 +285,10 @@ export const BoardCard = ({user, board, boardView, assignees, toggleAssignModal, ) : siteSpecific( - + // sci + {isDefined(board.creationDate) &&

@@ -299,6 +301,8 @@ export const BoardCard = ({user, board, boardView, assignees, toggleAssignModal, , + + // ada @@ -353,7 +357,7 @@ export const BoardCard = ({user, board, boardView, assignees, toggleAssignModal, - {isSetAssignments && } diff --git a/src/app/components/elements/cards/GameboardCard.tsx b/src/app/components/elements/cards/GameboardCard.tsx index bf6da5c28d..2d4a1fcc6e 100644 --- a/src/app/components/elements/cards/GameboardCard.tsx +++ b/src/app/components/elements/cards/GameboardCard.tsx @@ -18,15 +18,15 @@ interface GameboardCardProps extends React.HTMLAttributes { gameboard?: GameboardDTO; linkLocation?: GameboardLinkLocation; onDelete?: () => void; // if this exists, a delete button will be shown calling this function - setAssignmentsDetails?: { - groupCount?: number; - toggleAssignModal?: () => void; - } + openAssignModal?: () => void; + groupCount?: number; } // any children passed into this component will be rendered in the card body export const GameboardCard = (props: GameboardCardProps) => { - const {gameboard, linkLocation, onDelete, children, setAssignmentsDetails, ...rest} = props; + const {gameboard, linkLocation, onDelete, children, openAssignModal, groupCount, ...rest} = props; + + const isSetAssignments = isDefined(groupCount); const [showMore, setShowMore] = useState(false); const boardStagesAndDifficulties = useMemo(() => determineGameboardStagesAndDifficulties(gameboard), [gameboard]); @@ -42,8 +42,6 @@ export const GameboardCard = (props: GameboardCardProps) => { const boardSubjects = determineGameboardSubjects(gameboard); - const isSetAssignments = isDefined(setAssignmentsDetails); - const boardLink = gameboard && (isSetAssignments ? `/assignment/${gameboard.id}` : `${PATHS.GAMEBOARD}#${gameboard.id}` @@ -100,29 +98,24 @@ export const GameboardCard = (props: GameboardCardProps) => { : <>

{setAssignmentsDetails?.groupCount ?? 0}
- group{setAssignmentsDetails?.groupCount !== 1 && "s"} +
{groupCount ?? 0}
+ group{groupCount !== 1 && "s"} } - {isSetAssignments - ? above['md'](deviceSize) &&
- {isPhy && boardLink &&
- -
} - -
- : boardLink &&
- -
- } + {above['md'](deviceSize) &&
+ {isPhy && boardLink &&
+ +
} + +
} - {isSetAssignments && !above['md'](deviceSize) && - } diff --git a/src/app/components/pages/SetAssignments.tsx b/src/app/components/pages/SetAssignments.tsx index 55ec05315f..224704ee0f 100644 --- a/src/app/components/pages/SetAssignments.tsx +++ b/src/app/components/pages/SetAssignments.tsx @@ -14,17 +14,12 @@ import { } from "reactstrap"; import {Link, useLocation} from "react-router-dom"; import { - closeActiveModal, - openActiveModal, openIsaacBooksModal, selectors, setAssignBoardPath, useAppDispatch, useAppSelector, - useGetGroupsQuery, - useGetMySetAssignmentsQuery, - useUnassignGameboardMutation -} from "../../state"; + useGetGroupsQuery} from "../../state"; import {ShowLoading} from "../handlers/ShowLoading"; import {TitleAndBreadcrumb} from "../elements/TitleAndBreadcrumb"; import { @@ -47,7 +42,7 @@ import { useDeviceSize, useGameboards, } from "../../services"; -import {AssignmentDTO, GameboardDTO, RegisteredUserDTO} from "../../../IsaacApiTypes"; +import {AssignmentDTO, RegisteredUserDTO} from "../../../IsaacApiTypes"; import {BoardAssignee, AssignmentBoardOrder, Boards} from "../../../IsaacAppTypes"; import {BoardCard} from "../elements/cards/BoardCard"; import {RenderNothing} from "../elements/RenderNothing"; @@ -56,7 +51,6 @@ import {HorizontalScroller} from "../elements/inputs/HorizontalScroller"; import classNames from "classnames"; import {PromptBanner} from "../elements/cards/PromptBanner"; import { PageMetadata } from "../elements/PageMetadata"; -import { SetAssignmentsModal } from "../elements/modals/SetAssignmentsModal"; import { PageFragment } from "../elements/PageFragment"; import { useHistoryState } from "../../state/actions/history"; import { PageContainer } from "../elements/layout/PageContainer"; @@ -76,8 +70,6 @@ interface SetAssignmentsTableProps { setBoardCreator: (creator: BoardCreators) => void; boardOrder: AssignmentBoardOrder; setBoardOrder: (boardOrder: AssignmentBoardOrder) => void; - groupsByGameboard: { [p: string]: BoardAssignee[] }; - openAssignModal: (board: GameboardDTO) => void; } const PhyTable = (props: SetAssignmentsTableProps) => { @@ -85,7 +77,6 @@ const PhyTable = (props: SetAssignmentsTableProps) => { user, boards, boardSubject, boardView, boardTitleFilter, boardCreator, boardOrder, setBoardOrder, - groupsByGameboard, openAssignModal } = props; const filteredBoards = useMemo(() => { @@ -134,8 +125,7 @@ const PhyTable = (props: SetAssignmentsTableProps) => { user={user} board={board} boardView={boardView} - assignees={(isDefined(board?.id) && groupsByGameboard[board.id]) || []} - toggleAssignModal={() => openAssignModal(board)} + displayAssignmentInfo={true} />) } @@ -151,7 +141,6 @@ const CSTable = (props: SetAssignmentsTableProps) => { boardTitleFilter, setBoardTitleFilter, boardCreator, setBoardCreator, boardOrder, setBoardOrder, - groupsByGameboard, openAssignModal } = props; const tableHeader = @@ -221,8 +210,7 @@ const CSTable = (props: SetAssignmentsTableProps) => { user={user} board={board} boardView={boardView} - assignees={(isDefined(board?.id) && groupsByGameboard[board.id]) || []} - toggleAssignModal={() => openAssignModal(board)} + displayAssignmentInfo={true} />) } @@ -282,8 +270,6 @@ export const SetAssignments = () => { // We know the user is logged in and is at least a teacher in order to visit this page const user = useAppSelector(selectors.user.orNull) as RegisteredUserDTO; const {data: groups} = useGetGroupsQuery(false); - const {data: assignmentsSetByMe} = useGetMySetAssignmentsQuery(undefined); - const groupsByGameboard = useMemo(() => getAssigneesByBoard(assignmentsSetByMe), [assignmentsSetByMe]); const [boardCreator, setBoardCreator] = useHistoryState("boardCreator", BoardCreators.all); const [boardSubject, setBoardSubject] = useHistoryState("boardSubject", BoardSubjects.all); @@ -329,28 +315,17 @@ export const SetAssignments = () => { } }, [boardView, setBoardTitleFilter]); - const dispatch = useAppDispatch(); - const [unassignBoard] = useUnassignGameboardMutation(); - - const openAssignModal = useCallback((board: GameboardDTO) => { - dispatch(openActiveModal(SetAssignmentsModal({ - board, - groups: groups ?? [], - assignees: (isDefined(board) && isDefined(board?.id) && groupsByGameboard[board.id]) || [], - toggle: () => dispatch(closeActiveModal()), - unassignBoard - }))); - }, [dispatch, groups, groupsByGameboard, unassignBoard]); - - useEffect(() => { - if (boards && hashAnchor) { - setHashAnchor(undefined); - const board = boards.boards.find(b => b.id === hashAnchor); - if (board) { - openAssignModal(board); - } - } - }, [boards, hashAnchor, openAssignModal]); + // TODO: this won't be used going forwards, but historic assignment links ought to still work – perhaps have a conditional component instead to prevent calling + // all the hooks required in useSetAssignment unnecessarily + // useEffect(() => { + // if (boards && hashAnchor) { + // setHashAnchor(undefined); + // const board = boards.boards.find(b => b.id === hashAnchor); + // if (board) { + // openSetAssignmentModal(board); + // } + // } + // }, [boards, hashAnchor, openSetAssignmentModal]); // Page help const pageHelp = @@ -363,8 +338,7 @@ export const SetAssignments = () => { user, boards, boardSubject, setBoardSubject, boardView, switchView, boardTitleFilter, setBoardTitleFilter, - boardCreator, setBoardCreator, boardOrder, setBoardOrder, - groupsByGameboard, openAssignModal + boardCreator, setBoardCreator, boardOrder, setBoardOrder }; const filteredBoards = useMemo(() => @@ -413,8 +387,8 @@ export const SetAssignments = () => { : <>
- Use the assignment schedule page to view - assignments by start date and due date. + Use the assignment schedule page to view + assignments by start date and due date.
@@ -517,8 +491,7 @@ export const SetAssignments = () => { user={user} board={board} boardView={boardView} - assignees={(isDefined(board?.id) && groupsByGameboard[board.id]) || []} - toggleAssignModal={() => openAssignModal(board)} + displayAssignmentInfo /> )} diff --git a/src/app/services/setAssignment.ts b/src/app/services/setAssignment.ts new file mode 100644 index 0000000000..b1356f3dec --- /dev/null +++ b/src/app/services/setAssignment.ts @@ -0,0 +1,31 @@ +import { closeActiveModal, openActiveModal, useAppDispatch, useGetGroupsQuery, useGetMySetAssignmentsQuery, useUnassignGameboardMutation } from "../state"; +import { useCallback, useMemo } from "react"; +import { SetAssignmentsModal } from "../components/elements/modals/SetAssignmentsModal"; +import { getAssigneesByBoard } from "../components/pages/SetAssignments"; +import { isDefined } from "./miscUtils"; +import { GameboardDTO } from "../../IsaacApiTypes"; + +export const useSetAssignment = (board: GameboardDTO) => { + const dispatch = useAppDispatch(); + + const {data: groups} = useGetGroupsQuery(false); + const {data: assignmentsSetByMe} = useGetMySetAssignmentsQuery(undefined); + const groupsByGameboard = useMemo(() => getAssigneesByBoard(assignmentsSetByMe), [assignmentsSetByMe]); + const [unassignBoard] = useUnassignGameboardMutation(); + + const assignees = useMemo(() => { + return isDefined(board.id) && isDefined(groupsByGameboard[board.id]) ? groupsByGameboard[board.id] : []; + }, [board, groupsByGameboard]); + + const openAssignModal = useCallback(() => { + dispatch(openActiveModal(SetAssignmentsModal({ + board, + groups: groups ?? [], + assignees, + toggle: () => dispatch(closeActiveModal()), + unassignBoard + }))); + }, [assignees, board, dispatch, groups, unassignBoard]); + + return { openAssignModal, assignees }; +}; From c14bc5a0cb4da84aea11becf3d8b389fa49b8fdb Mon Sep 17 00:00:00 2001 From: Jaycie Brown Date: Wed, 22 Apr 2026 17:36:45 +0100 Subject: [PATCH 03/28] Rename My q. decks => My saved decks --- src/app/components/elements/StudentDashboard.tsx | 2 +- src/app/components/pages/Gameboard.tsx | 2 +- src/app/components/pages/MyGameboards.tsx | 2 +- src/app/components/site/phy/NavigationMenuPhy.tsx | 2 +- src/app/services/searchResults.ts | 4 ++-- 5 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/app/components/elements/StudentDashboard.tsx b/src/app/components/elements/StudentDashboard.tsx index 109c07c80b..59d6681b30 100644 --- a/src/app/components/elements/StudentDashboard.tsx +++ b/src/app/components/elements/StudentDashboard.tsx @@ -178,7 +178,7 @@ const MyIsaacPanel = ({assignmentsCount, quizzesCount}: MyIsaacPanelProps) => {

More in My Isaac

- My question decks + My saved decks My assignments diff --git a/src/app/components/pages/Gameboard.tsx b/src/app/components/pages/Gameboard.tsx index 754d5b8078..a7f75ae975 100644 --- a/src/app/components/pages/Gameboard.tsx +++ b/src/app/components/pages/Gameboard.tsx @@ -111,7 +111,7 @@ export const Gameboard = () => { onClick={() => setAssignBoardPath(PATHS.SET_ASSIGNMENTS)} color="keyline" block > - {siteSpecific("Save to My question decks", "Save to My quizzes")} + {siteSpecific("Save this deck", "Save to My quizzes")} diff --git a/src/app/components/pages/MyGameboards.tsx b/src/app/components/pages/MyGameboards.tsx index e94f0d340a..0bf6ef35bc 100644 --- a/src/app/components/pages/MyGameboards.tsx +++ b/src/app/components/pages/MyGameboards.tsx @@ -189,7 +189,7 @@ export const MyGameboards = ({user}: {user: RegisteredUserDTO}) => { return + } sidebar={siteSpecific( void}) => { {isTutorOrAbove(user) &&
STUDENT
}
    - My question decks + My saved decks My assignments diff --git a/src/app/services/searchResults.ts b/src/app/services/searchResults.ts index dd0824c7ad..e24fae6d9b 100644 --- a/src/app/services/searchResults.ts +++ b/src/app/services/searchResults.ts @@ -180,8 +180,8 @@ const siteShortcuts: SearchShortcut[] = siteSpecific([ type: SEARCH_RESULT_TYPE.SHORTCUT }, { id: "my_gameboards", - title: "My question decks", - terms: ["gameboards", "gameboard", "my gameboards", "boards", "question deck", "question decks", "my question decks", "decks"], + title: "My saved decks", + terms: ["gameboards", "gameboard", "my gameboards", "boards", "question deck", "question decks", "my question decks", "saved decks", "decks"], summary: "View your saved question decks.", url: PATHS.MY_GAMEBOARDS, hash: "my_gameboards", From 167d888fa8355859479562ee32d971ec703cdb9d Mon Sep 17 00:00:00 2001 From: Jaycie Brown Date: Thu, 23 Apr 2026 15:44:59 +0100 Subject: [PATCH 04/28] Add manual saving/unsaving for decks (sci-only) I have refactored the Ada "Remove board" button for consistency, but I'd really like to remove it in favour of the Sci approach. Decision on this pending from Ada team. Co-authored-by: Copilot --- public/assets/common/icons/star-fill.svg | 3 + public/assets/common/icons/star.svg | 3 + .../components/elements/SaveBoardButton.tsx | 97 +++++++++++++++++++ .../components/elements/cards/BoardCard.tsx | 56 ++++------- .../elements/cards/GameboardCard.tsx | 13 +-- src/scss/common/icons.scss | 13 +++ 6 files changed, 142 insertions(+), 43 deletions(-) create mode 100644 public/assets/common/icons/star-fill.svg create mode 100644 public/assets/common/icons/star.svg create mode 100644 src/app/components/elements/SaveBoardButton.tsx diff --git a/public/assets/common/icons/star-fill.svg b/public/assets/common/icons/star-fill.svg new file mode 100644 index 0000000000..889734c1fc --- /dev/null +++ b/public/assets/common/icons/star-fill.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/assets/common/icons/star.svg b/public/assets/common/icons/star.svg new file mode 100644 index 0000000000..a890973494 --- /dev/null +++ b/public/assets/common/icons/star.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/app/components/elements/SaveBoardButton.tsx b/src/app/components/elements/SaveBoardButton.tsx new file mode 100644 index 0000000000..fd16da1cd5 --- /dev/null +++ b/src/app/components/elements/SaveBoardButton.tsx @@ -0,0 +1,97 @@ +import React, { useCallback, useMemo } from "react"; +import { IconButton } from "./AffixButton"; +import { GameboardDTO } from "../../../IsaacApiTypes"; +import classNames from "classnames"; +import { ButtonProps } from "reactstrap"; +import { saveGameboard, selectors, showErrorToast, unlinkUserFromGameboard, useAppDispatch, useAppSelector } from "../../state"; +import { isAdminOrEventManager, siteSpecific } from "../../services"; + +interface SaveBoardButtonProps extends ButtonProps { + board: GameboardDTO; + size?: "sm" | "md"; // "md" default (as used for PageMetadata buttons); "sm" aligns with regular .btn padding + hasAssignedGroups?: boolean; +} + +export const SaveBoardButton = (props: SaveBoardButtonProps) => { + const { board, size, hasAssignedGroups: _, className, ...rest } = props; + + const dispatch = useAppDispatch(); + const user = useAppSelector(selectors.user.loggedInOrNull); + + const isLinked = useMemo(() => board.savedToCurrentUser, [board]); + + const linkBoard = useCallback(() => { + if (!user || !board) return; + void dispatch(saveGameboard({ + boardId: board.id ?? "", + boardTitle: board.title, + user, + })); + }, [user, board, dispatch]); + + const unlinkBoard = useCallback(() => { + if (!user || !board) return; + const confirmMessage = board.ownerUserId === user.id && !board.tags?.includes("ISAAC_BOARD") + ? `Are you sure you want to unlink your board '${board.title}' from your account? You'll only be able to find it again if you've set it as an assignment.` + : `Are you sure you want to unlink '${board.title}' from your account?`; + if (confirm(confirmMessage)) { + void dispatch(unlinkUserFromGameboard({ + boardId: board.id ?? "", + boardTitle: board.title + })); + } + }, [user, board, dispatch]); + + + return { + e.preventDefault(); + if (isLinked) { + unlinkBoard(); + } else { + linkBoard(); + } + }} + {...rest} + />; +}; + + +//! ADA-ONLY; I'd really like to move to exclusively using the above, but while there is inconsistency in Ada saving attempted decks automatically vs Sci saving only manually, +//! this supports the Ada use case. +export const RemoveBoardButton = (props: SaveBoardButtonProps) => { + const { board, size, hasAssignedGroups, className, ...rest } = props; + + const dispatch = useAppDispatch(); + const user = useAppSelector(selectors.user.loggedInOrNull); + + function confirmDeleteBoard() { + if (hasAssignedGroups) { + if (isAdminOrEventManager(user)) { + alert(`Warning: You currently have groups assigned to this ${siteSpecific("question deck", "quiz")}. If you delete this your groups will still be assigned but you won't be able to unassign them or see the ${siteSpecific("question deck", "quiz")} on the ${siteSpecific("Set assignments", "Quizzes")} page.`); + } else { + dispatch(showErrorToast(`${siteSpecific("Question Deck", "Quiz")} Deletion Not Allowed`, `You have groups assigned to this gameboard. To delete this ${siteSpecific("question deck", "quiz")}, you must unassign all groups.`)); + return; + } + } + + if (confirm(`Are you sure you want to remove '${board.title}' from your account?`)) { + void dispatch(unlinkUserFromGameboard({boardId: board.id, boardTitle: board.title})); + } + } + + return ; +}; diff --git a/src/app/components/elements/cards/BoardCard.tsx b/src/app/components/elements/cards/BoardCard.tsx index 0750d9eefd..232a9c69d4 100644 --- a/src/app/components/elements/cards/BoardCard.tsx +++ b/src/app/components/elements/cards/BoardCard.tsx @@ -37,9 +37,9 @@ import {Link} from "react-router-dom"; import {BoardAssignee, Boards} from "../../../../IsaacAppTypes"; import indexOf from "lodash/indexOf"; import { GameboardCard, GameboardLinkLocation } from "./GameboardCard"; -import { IconButton } from "../AffixButton"; import { SupersededDeprecatedBoardContentWarning } from "../../navigation/SupersededDeprecatedWarning"; import { useSetAssignment } from "../../../services/setAssignment"; +import { RemoveBoardButton, SaveBoardButton } from "../SaveBoardButton"; interface HexagonGroupsButtonProps { @@ -146,7 +146,6 @@ export const BoardCard = ({user, board, boardView, displayAssignmentInfo, setSel const hasAssignedGroups = isDefined(assignees?.length) && assignees.length > 0; - const dispatch = useAppDispatch(); const deviceSize = useDeviceSize(); const updateBoardSelection = (board: GameboardDTO, checked: boolean) => { @@ -158,21 +157,6 @@ export const BoardCard = ({user, board, boardView, displayAssignmentInfo, setSel } }; - function confirmDeleteBoard() { - if (hasAssignedGroups) { - if (isAdminOrEventManager(user)) { - alert(`Warning: You currently have groups assigned to this ${siteSpecific("question deck", "quiz")}. If you delete this your groups will still be assigned but you won't be able to unassign them or see the ${siteSpecific("question deck", "quiz")} on the ${siteSpecific("Set assignments", "Quizzes")} page.`); - } else { - dispatch(showErrorToast(`${siteSpecific("Question Deck", "Quiz")} Deletion Not Allowed`, `You have groups assigned to this gameboard. To delete this ${siteSpecific("question deck", "quiz")}, you must unassign all groups.`)); - return; - } - } - - if (confirm(`Are you sure you want to remove '${board.title}' from your account?`)) { - dispatch(unlinkUserFromGameboard({boardId: board.id, boardTitle: board.title})); - } - } - const boardSubjects = determineGameboardSubjects(board); const boardStagesAndDifficulties = determineGameboardStagesAndDifficulties(board); @@ -237,7 +221,7 @@ export const BoardCard = ({user, board, boardView, displayAssignmentInfo, setSel
} {isAda && - + } : @@ -266,28 +250,30 @@ export const BoardCard = ({user, board, boardView, displayAssignmentInfo, setSel
} - {siteSpecific( - - , - - e.id === board.id)} - onChange={(event: React.ChangeEvent) => - board && updateBoardSelection(board, event.target.checked) - } aria-label="Delete quiz" - /> - )} + {siteSpecific( + + + , + + e.id === board.id)} + onChange={(event: React.ChangeEvent) => + board && updateBoardSelection(board, event.target.checked) + } aria-label="Delete quiz" + /> + + )} } ) : siteSpecific( // sci @@ -356,7 +342,7 @@ export const BoardCard = ({user, board, boardView, displayAssignmentInfo, setSel - + {isSetAssignments && } diff --git a/src/app/components/elements/cards/GameboardCard.tsx b/src/app/components/elements/cards/GameboardCard.tsx index 2d4a1fcc6e..8f903025ab 100644 --- a/src/app/components/elements/cards/GameboardCard.tsx +++ b/src/app/components/elements/cards/GameboardCard.tsx @@ -7,6 +7,7 @@ import { Link } from "react-router-dom"; import classNames from "classnames"; import { Spacer } from "../Spacer"; import { ShareLink } from "../ShareLink"; +import { SaveBoardButton } from "../SaveBoardButton"; export enum GameboardLinkLocation { // where on the card can the user click to navigate to the gameboard @@ -17,14 +18,13 @@ export enum GameboardLinkLocation { interface GameboardCardProps extends React.HTMLAttributes { gameboard?: GameboardDTO; linkLocation?: GameboardLinkLocation; - onDelete?: () => void; // if this exists, a delete button will be shown calling this function openAssignModal?: () => void; groupCount?: number; } // any children passed into this component will be rendered in the card body export const GameboardCard = (props: GameboardCardProps) => { - const {gameboard, linkLocation, onDelete, children, openAssignModal, groupCount, ...rest} = props; + const {gameboard, linkLocation, children, openAssignModal, groupCount, ...rest} = props; const isSetAssignments = isDefined(groupCount); @@ -105,17 +105,18 @@ export const GameboardCard = (props: GameboardCardProps) => { } {above['md'](deviceSize) &&
+ {isPhy && gameboard && } {isPhy && boardLink &&
}
} {!above['md'](deviceSize) && } @@ -166,14 +167,10 @@ export const GameboardCard = (props: GameboardCardProps) => { if (gameboard && linkLocation === GameboardLinkLocation.Card && boardLink) { return {card} - {onDelete && } ; } else { return
{card} - {onDelete && }
; } }; diff --git a/src/scss/common/icons.scss b/src/scss/common/icons.scss index 4622b2a00f..f1d19528f2 100644 --- a/src/scss/common/icons.scss +++ b/src/scss/common/icons.scss @@ -168,6 +168,19 @@ img[aria-disabled="true"] { @include svg-icon('/assets/common/icons/dash.svg', var(--icon-size), var(--icon-size), var(--mask-size, 60%)); } +.icon-star { + @include svg-icon-layered( + '/assets/common/icons/star.svg', + '/assets/common/icons/star-fill.svg', + calc(var(--icon-size) * 1.2), calc(var(--icon-size) * 1.2), var(--mask-size, 60%) + ); + margin: calc(var(--icon-size) * (1 - 1.2)/2) !important; + + &:not(.fill)::before { + background-color: transparent !important; + } +} + .icon-ai { @include svg-icon('/assets/common/icons/ai.svg', var(--icon-size), var(--icon-size), var(--mask-size, 60%)); } From dde2c73ef235980b56dd46c555a0a66f51207e60 Mon Sep 17 00:00:00 2001 From: Jaycie Brown Date: Fri, 24 Apr 2026 11:04:45 +0100 Subject: [PATCH 05/28] Restructure board cards to better fit new content Co-authored-by: Copilot --- .../elements/cards/GameboardCard.tsx | 101 +++++++++--------- src/scss/phy/boards.scss | 2 +- 2 files changed, 52 insertions(+), 51 deletions(-) diff --git a/src/app/components/elements/cards/GameboardCard.tsx b/src/app/components/elements/cards/GameboardCard.tsx index 8f903025ab..6ff16f0578 100644 --- a/src/app/components/elements/cards/GameboardCard.tsx +++ b/src/app/components/elements/cards/GameboardCard.tsx @@ -1,7 +1,7 @@ import React, { useMemo, useState } from "react"; import { GameboardDTO } from "../../../../IsaacApiTypes"; import { Row, Col, Button, Label, Collapse } from "reactstrap"; -import { generateGameboardSubjectHexagons, isDefined, above, HUMAN_SUBJECTS, stageLabelMap, difficultyShortLabelMap, PATHS, tags, determineGameboardStagesAndDifficulties, determineGameboardSubjects, TAG_ID, useDeviceSize, Subject, isPhy } from "../../../services"; +import { generateGameboardSubjectHexagons, isDefined, above, HUMAN_SUBJECTS, stageLabelMap, difficultyShortLabelMap, PATHS, tags, determineGameboardStagesAndDifficulties, determineGameboardSubjects, TAG_ID, useDeviceSize, Subject, isPhy, below } from "../../../services"; import { HexIcon } from "../svg/HexIcon"; import { Link } from "react-router-dom"; import classNames from "classnames"; @@ -22,6 +22,37 @@ interface GameboardCardProps extends React.HTMLAttributes { groupCount?: number; } +interface CardUsageInfoProps extends React.HTMLAttributes { + gameboard?: GameboardDTO; + groupCount?: number; + isSetAssignments?: boolean; +} + +// "Attempted/Correct" percentages or "Assigned to X groups" +const CardUsageInfo = ({ gameboard, groupCount, isSetAssignments, className, ...rest }: CardUsageInfoProps) => { + return
+ {!isSetAssignments + ? <> + + + + : <> + + + } +
; +}; + // any children passed into this component will be rendered in the card body export const GameboardCard = (props: GameboardCardProps) => { const {gameboard, linkLocation, children, openAssignModal, groupCount, ...rest} = props; @@ -49,8 +80,8 @@ export const GameboardCard = (props: GameboardCardProps) => { const card =
- -
+ +
{generateGameboardSubjectHexagons(boardSubjects)} @@ -69,62 +100,32 @@ export const GameboardCard = (props: GameboardCardProps) => { {boardSubjects.map((subject) => {HUMAN_SUBJECTS[subject]})}
}
+ {!below['xs'](deviceSize) && }
{children} - - {above[isSetAssignments ? 'sm' : 'md'](deviceSize) && } + - -
- {!isSetAssignments - ? <> - - - - : <> - - - } -
- {above['md'](deviceSize) &&
- {isPhy && gameboard && } - {isPhy && boardLink &&
- -
} - +
+ + +
+ {isPhy && gameboard && } + {isPhy && boardLink &&
+
} + +
+
- {!above['md'](deviceSize) && - - } - - {!above[isSetAssignments ? 'sm' : 'md'](deviceSize) && } - - + {/* collapsed info */} diff --git a/src/scss/phy/boards.scss b/src/scss/phy/boards.scss index d43723c631..f8ff2e4489 100644 --- a/src/scss/phy/boards.scss +++ b/src/scss/phy/boards.scss @@ -42,7 +42,7 @@ .board-bubble-info { width: 100%; - height: 39px; + height: 32px; position: relative; z-index: 5; align-content: center; From 7d7aec7e57206bf7350b9d1bf2369fd66a4ea8b6 Mon Sep 17 00:00:00 2001 From: Jaycie Brown Date: Fri, 24 Apr 2026 12:29:19 +0100 Subject: [PATCH 06/28] Share manual deck save changes with Ada Co-authored-by: Copilot --- src/app/components/elements/Gameboards.tsx | 6 +-- .../components/elements/SaveBoardButton.tsx | 41 +------------------ .../components/elements/cards/BoardCard.tsx | 6 +-- src/app/components/pages/Gameboard.tsx | 10 ++++- src/app/components/pages/SetAssignments.tsx | 2 +- 5 files changed, 18 insertions(+), 47 deletions(-) diff --git a/src/app/components/elements/Gameboards.tsx b/src/app/components/elements/Gameboards.tsx index 1c4466766b..b73905b00e 100644 --- a/src/app/components/elements/Gameboards.tsx +++ b/src/app/components/elements/Gameboards.tsx @@ -78,16 +78,16 @@ const CSTable = (props: GameboardsTableProps) => { {siteSpecific( <> - Delete + Unlink , <> Share {selectedBoards.length ? - : "Delete" + : "Unlink" } diff --git a/src/app/components/elements/SaveBoardButton.tsx b/src/app/components/elements/SaveBoardButton.tsx index fd16da1cd5..47bb1e7c10 100644 --- a/src/app/components/elements/SaveBoardButton.tsx +++ b/src/app/components/elements/SaveBoardButton.tsx @@ -3,17 +3,15 @@ import { IconButton } from "./AffixButton"; import { GameboardDTO } from "../../../IsaacApiTypes"; import classNames from "classnames"; import { ButtonProps } from "reactstrap"; -import { saveGameboard, selectors, showErrorToast, unlinkUserFromGameboard, useAppDispatch, useAppSelector } from "../../state"; -import { isAdminOrEventManager, siteSpecific } from "../../services"; +import { saveGameboard, selectors, unlinkUserFromGameboard, useAppDispatch, useAppSelector } from "../../state"; interface SaveBoardButtonProps extends ButtonProps { board: GameboardDTO; size?: "sm" | "md"; // "md" default (as used for PageMetadata buttons); "sm" aligns with regular .btn padding - hasAssignedGroups?: boolean; } export const SaveBoardButton = (props: SaveBoardButtonProps) => { - const { board, size, hasAssignedGroups: _, className, ...rest } = props; + const { board, size, className, ...rest } = props; const dispatch = useAppDispatch(); const user = useAppSelector(selectors.user.loggedInOrNull); @@ -60,38 +58,3 @@ export const SaveBoardButton = (props: SaveBoardButtonProps) => { {...rest} />; }; - - -//! ADA-ONLY; I'd really like to move to exclusively using the above, but while there is inconsistency in Ada saving attempted decks automatically vs Sci saving only manually, -//! this supports the Ada use case. -export const RemoveBoardButton = (props: SaveBoardButtonProps) => { - const { board, size, hasAssignedGroups, className, ...rest } = props; - - const dispatch = useAppDispatch(); - const user = useAppSelector(selectors.user.loggedInOrNull); - - function confirmDeleteBoard() { - if (hasAssignedGroups) { - if (isAdminOrEventManager(user)) { - alert(`Warning: You currently have groups assigned to this ${siteSpecific("question deck", "quiz")}. If you delete this your groups will still be assigned but you won't be able to unassign them or see the ${siteSpecific("question deck", "quiz")} on the ${siteSpecific("Set assignments", "Quizzes")} page.`); - } else { - dispatch(showErrorToast(`${siteSpecific("Question Deck", "Quiz")} Deletion Not Allowed`, `You have groups assigned to this gameboard. To delete this ${siteSpecific("question deck", "quiz")}, you must unassign all groups.`)); - return; - } - } - - if (confirm(`Are you sure you want to remove '${board.title}' from your account?`)) { - void dispatch(unlinkUserFromGameboard({boardId: board.id, boardTitle: board.title})); - } - } - - return ; -}; diff --git a/src/app/components/elements/cards/BoardCard.tsx b/src/app/components/elements/cards/BoardCard.tsx index 232a9c69d4..574a09efcb 100644 --- a/src/app/components/elements/cards/BoardCard.tsx +++ b/src/app/components/elements/cards/BoardCard.tsx @@ -39,7 +39,7 @@ import indexOf from "lodash/indexOf"; import { GameboardCard, GameboardLinkLocation } from "./GameboardCard"; import { SupersededDeprecatedBoardContentWarning } from "../../navigation/SupersededDeprecatedWarning"; import { useSetAssignment } from "../../../services/setAssignment"; -import { RemoveBoardButton, SaveBoardButton } from "../SaveBoardButton"; +import { SaveBoardButton } from "../SaveBoardButton"; interface HexagonGroupsButtonProps { @@ -221,7 +221,7 @@ export const BoardCard = ({user, board, boardView, displayAssignmentInfo, setSel
} {isAda && - + } : @@ -342,7 +342,7 @@ export const BoardCard = ({user, board, boardView, displayAssignmentInfo, setSel - + {isSetAssignments && } diff --git a/src/app/components/pages/Gameboard.tsx b/src/app/components/pages/Gameboard.tsx index a7f75ae975..06867b739c 100644 --- a/src/app/components/pages/Gameboard.tsx +++ b/src/app/components/pages/Gameboard.tsx @@ -35,6 +35,7 @@ import {ListView} from "../elements/list-groups/ListView"; import { GameboardSidebar } from "../elements/sidebar/GameboardSidebar"; import { SupersededDeprecatedBoardContentWarning } from "../navigation/SupersededDeprecatedWarning"; import { PageContainer } from "../elements/layout/PageContainer"; +import { SaveBoardButton } from "../elements/SaveBoardButton"; export const Gameboard = () => { const dispatch = useAppDispatch(); @@ -89,7 +90,14 @@ export const Gameboard = () => { undefined )} > - + + {user &&
+ +
} +
{user && isTutorOrAbove(user) diff --git a/src/app/components/pages/SetAssignments.tsx b/src/app/components/pages/SetAssignments.tsx index 224704ee0f..bdab86a970 100644 --- a/src/app/components/pages/SetAssignments.tsx +++ b/src/app/components/pages/SetAssignments.tsx @@ -165,7 +165,7 @@ const CSTable = (props: SetAssignmentsTableProps) => { Manage Share - Delete + Unlink ; return
From 7f8598486ec5983fe5a086f69fa0f1df134cb4a6 Mon Sep 17 00:00:00 2001 From: Jaycie Brown Date: Fri, 24 Apr 2026 15:59:08 +0100 Subject: [PATCH 07/28] Align expanded board views with board ALVIs Co-authored-by: Copilot --- .../components/elements/SaveBoardButton.tsx | 8 +++-- .../elements/cards/GameboardCard.tsx | 30 +++++++++++----- .../list-groups/AbstractListViewItem.tsx | 34 ++++++++----------- src/scss/common/animation.scss | 15 ++++++++ src/scss/phy/assignments.scss | 2 +- src/scss/phy/gameboard.scss | 10 ++++++ src/scss/phy/isaac.scss | 1 + src/scss/phy/list-groups.scss | 6 +--- 8 files changed, 70 insertions(+), 36 deletions(-) create mode 100644 src/scss/common/animation.scss diff --git a/src/app/components/elements/SaveBoardButton.tsx b/src/app/components/elements/SaveBoardButton.tsx index 47bb1e7c10..1890767226 100644 --- a/src/app/components/elements/SaveBoardButton.tsx +++ b/src/app/components/elements/SaveBoardButton.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useMemo } from "react"; +import React, { useCallback, useMemo, useState } from "react"; import { IconButton } from "./AffixButton"; import { GameboardDTO } from "../../../IsaacApiTypes"; import classNames from "classnames"; @@ -17,9 +17,11 @@ export const SaveBoardButton = (props: SaveBoardButtonProps) => { const user = useAppSelector(selectors.user.loggedInOrNull); const isLinked = useMemo(() => board.savedToCurrentUser, [board]); + const [justLinked, setJustLinked] = useState(false); const linkBoard = useCallback(() => { if (!user || !board) return; + setJustLinked(true); void dispatch(saveGameboard({ boardId: board.id ?? "", boardTitle: board.title, @@ -33,6 +35,7 @@ export const SaveBoardButton = (props: SaveBoardButtonProps) => { ? `Are you sure you want to unlink your board '${board.title}' from your account? You'll only be able to find it again if you've set it as an assignment.` : `Are you sure you want to unlink '${board.title}' from your account?`; if (confirm(confirmMessage)) { + setJustLinked(false); void dispatch(unlinkUserFromGameboard({ boardId: board.id ?? "", boardTitle: board.title @@ -43,10 +46,9 @@ export const SaveBoardButton = (props: SaveBoardButtonProps) => { return { e.preventDefault(); if (isLinked) { diff --git a/src/app/components/elements/cards/GameboardCard.tsx b/src/app/components/elements/cards/GameboardCard.tsx index 6ff16f0578..dc212067bf 100644 --- a/src/app/components/elements/cards/GameboardCard.tsx +++ b/src/app/components/elements/cards/GameboardCard.tsx @@ -1,6 +1,6 @@ import React, { useMemo, useState } from "react"; import { GameboardDTO } from "../../../../IsaacApiTypes"; -import { Row, Col, Button, Label, Collapse } from "reactstrap"; +import { Row, Col, Button, Label, Collapse, Badge } from "reactstrap"; import { generateGameboardSubjectHexagons, isDefined, above, HUMAN_SUBJECTS, stageLabelMap, difficultyShortLabelMap, PATHS, tags, determineGameboardStagesAndDifficulties, determineGameboardSubjects, TAG_ID, useDeviceSize, Subject, isPhy, below } from "../../../services"; import { HexIcon } from "../svg/HexIcon"; import { Link } from "react-router-dom"; @@ -14,14 +14,20 @@ export enum GameboardLinkLocation { Card, Title } - -interface GameboardCardProps extends React.HTMLAttributes { - gameboard?: GameboardDTO; - linkLocation?: GameboardLinkLocation; - openAssignModal?: () => void; - groupCount?: number; +interface BoardItemIndicatorProps extends React.HTMLAttributes { + count: number; + type: "list-view" | "board-card" } +export const BoardItemIndicator = ({count, type, ...rest}: BoardItemIndicatorProps) => { + return + {count < 100 ? count : "99+"} + ; +}; + interface CardUsageInfoProps extends React.HTMLAttributes { gameboard?: GameboardDTO; groupCount?: number; @@ -53,6 +59,13 @@ const CardUsageInfo = ({ gameboard, groupCount, isSetAssignments, className, ...
; }; +interface GameboardCardProps extends React.HTMLAttributes { + gameboard?: GameboardDTO; + linkLocation?: GameboardLinkLocation; + openAssignModal?: () => void; + groupCount?: number; +} + // any children passed into this component will be rendered in the card body export const GameboardCard = (props: GameboardCardProps) => { const {gameboard, linkLocation, children, openAssignModal, groupCount, ...rest} = props; @@ -82,11 +95,12 @@ export const GameboardCard = (props: GameboardCardProps) => {
-
+
{generateGameboardSubjectHexagons(boardSubjects)}
+ {gameboard?.contents && }

diff --git a/src/app/components/elements/list-groups/AbstractListViewItem.tsx b/src/app/components/elements/list-groups/AbstractListViewItem.tsx index 89cbcca7d2..315798d99b 100644 --- a/src/app/components/elements/list-groups/AbstractListViewItem.tsx +++ b/src/app/components/elements/list-groups/AbstractListViewItem.tsx @@ -3,7 +3,7 @@ import React, { HTMLAttributes, ReactNode } from "react"; import { StageAndDifficultySummaryIcons } from "../StageAndDifficultySummaryIcons"; import { ViewingContext} from "../../../../IsaacAppTypes"; import classNames from "classnames"; -import { Badge, Button, Col, ListGroupItem } from "reactstrap"; +import { Button, Col, ListGroupItem } from "reactstrap"; import { CompletionState, GameboardDTO } from "../../../../IsaacApiTypes"; import { above, below, isAda, isDefined, isPhy, isStaff, isTeacherOrAbove, siteSpecific, Subject, useDeviceSize } from "../../../services"; import { TitleIcon, TitleIconProps } from "../PageTitle"; @@ -16,6 +16,8 @@ import { ContentPropertyTags } from "../ContentPropertyTags"; import { LLMFreeTextQuestionIndicator } from "../LLMFreeTextQuestionIndicator"; import { CrossTopicQuestionIndicator } from "../CrossTopicQuestionIndicator"; import { SupersededDeprecatedBoardContentWarning } from "../../navigation/SupersededDeprecatedWarning"; +import { SaveBoardButton } from "../SaveBoardButton"; +import { BoardItemIndicator } from "../cards/GameboardCard"; const Breadcrumb = ({breadcrumb}: {breadcrumb: string[]}) => { return <> @@ -52,16 +54,6 @@ export const StatusDisplay = (props: StatusDisplayProps) => { } }; -interface ItemCountProps extends React.HTMLAttributes { - count: number; -} - -const ItemCount = ({count, ...rest}: ItemCountProps) => { - return - {count < 100 ? count : "99+"} - ; -}; - export interface ListViewTagProps extends HTMLAttributes { tag: string; url?: string; @@ -99,7 +91,8 @@ const GameboardAssign = ({board}: {board?: GameboardDTO}) => { const [ getAssignments ] = useLazyGetMySetAssignmentsQuery(); const [ unassignBoard ] = useUnassignGameboardMutation(); - return

@@ -236,8 +229,10 @@ export const AbstractListViewItem = ({title, icon, subject, subtitle, breadcrumb {isPhy && isItem && fullWidth && typedProps.status && typedProps.status !== CompletionState.ALL_CORRECT && } - {isGameboard && fullWidth && isTeacherOrAbove(user) &&
- + {isGameboard && fullWidth && isDefined(typedProps.board) &&
+ {/* note order flipped relative to non-fullWidth to keep Assign at highest priority visually */} + {isTeacherOrAbove(user) && } +
} {isCard && typedProps.linkTags &&
@@ -253,9 +248,10 @@ export const AbstractListViewItem = ({title, icon, subject, subtitle, breadcrumb {isItem && typedProps.audienceViews &&
0})}>
} - {isGameboard && isTeacherOrAbove(user) && - - } + {isGameboard && typedProps.board &&
+ + {isTeacherOrAbove(user) && } +
} {isQuiz && } diff --git a/src/scss/common/animation.scss b/src/scss/common/animation.scss new file mode 100644 index 0000000000..c556975c73 --- /dev/null +++ b/src/scss/common/animation.scss @@ -0,0 +1,15 @@ +@keyframes star-select { + 0% { + transform: scale(1) rotate(0deg); + } + 50% { + transform: scale(1.2) rotate(72deg); + } + 100% { + transform: scale(1) rotate(144deg); + } +} + +.anim-star-select { + animation: star-select 0.3s ease-out; +} diff --git a/src/scss/phy/assignments.scss b/src/scss/phy/assignments.scss index f101f9b357..f15c87e9f3 100644 --- a/src/scss/phy/assignments.scss +++ b/src/scss/phy/assignments.scss @@ -19,5 +19,5 @@ } .assignment-hex { - z-index: 5; + z-index: 6; } diff --git a/src/scss/phy/gameboard.scss b/src/scss/phy/gameboard.scss index 2d8d696fe7..d287bd12ad 100644 --- a/src/scss/phy/gameboard.scss +++ b/src/scss/phy/gameboard.scss @@ -76,3 +76,13 @@ button.btn.delete-button { .bg-wildcard { background-color: $color-misc-pale-yellow; } + +.hex-status-indicator { + position: absolute; + top: 2px; + right: 0px; + z-index: 7; + > i { + display: inline-block; + } +} diff --git a/src/scss/phy/isaac.scss b/src/scss/phy/isaac.scss index bb0cf47a74..94cef2c110 100644 --- a/src/scss/phy/isaac.scss +++ b/src/scss/phy/isaac.scss @@ -376,6 +376,7 @@ $theme-colors-border-subtle: map-merge($theme-colors-border-subtle, $custom-colo @import "../common/mixins"; @import "../common/_utils"; @import "../common/transitions"; +@import "../common/animation"; // theming @import "color-theme"; diff --git a/src/scss/phy/list-groups.scss b/src/scss/phy/list-groups.scss index 0175a04fd1..f6e0b27815 100644 --- a/src/scss/phy/list-groups.scss +++ b/src/scss/phy/list-groups.scss @@ -160,12 +160,8 @@ } .list-view-status-indicator { - position: absolute; - top: 2px; + @extend .hex-status-indicator; right: 16px; - > i { - display: inline-block; - } } .list-group-links li.disabled { From 5387fc2e20063dbaa883881c916e15d172d8fe44 Mon Sep 17 00:00:00 2001 From: Jaycie Brown Date: Fri, 24 Apr 2026 16:03:03 +0100 Subject: [PATCH 08/28] Fix board hex indicator position Co-authored-by: Copilot --- src/app/components/elements/cards/GameboardCard.tsx | 2 +- src/scss/phy/gameboard.scss | 8 ++++++-- src/scss/phy/list-groups.scss | 2 +- 3 files changed, 8 insertions(+), 4 deletions(-) diff --git a/src/app/components/elements/cards/GameboardCard.tsx b/src/app/components/elements/cards/GameboardCard.tsx index dc212067bf..6ab0351643 100644 --- a/src/app/components/elements/cards/GameboardCard.tsx +++ b/src/app/components/elements/cards/GameboardCard.tsx @@ -22,7 +22,7 @@ interface BoardItemIndicatorProps extends React.HTMLAttributes export const BoardItemIndicator = ({count, type, ...rest}: BoardItemIndicatorProps) => { return {count < 100 ? count : "99+"} ; diff --git a/src/scss/phy/gameboard.scss b/src/scss/phy/gameboard.scss index d287bd12ad..e12e0aafe0 100644 --- a/src/scss/phy/gameboard.scss +++ b/src/scss/phy/gameboard.scss @@ -77,12 +77,16 @@ button.btn.delete-button { background-color: $color-misc-pale-yellow; } -.hex-status-indicator { +%hex-status-indicator { position: absolute; top: 2px; - right: 0px; z-index: 7; > i { display: inline-block; } } + +.board-card-status-indicator { + @extend %hex-status-indicator; + right: 0px; +} diff --git a/src/scss/phy/list-groups.scss b/src/scss/phy/list-groups.scss index f6e0b27815..2bed2fc50e 100644 --- a/src/scss/phy/list-groups.scss +++ b/src/scss/phy/list-groups.scss @@ -160,7 +160,7 @@ } .list-view-status-indicator { - @extend .hex-status-indicator; + @extend %hex-status-indicator; right: 16px; } From 0c539461efe24ea1abdc1bb745869c598a9d4ea7 Mon Sep 17 00:00:00 2001 From: Jaycie Brown Date: Fri, 24 Apr 2026 17:36:55 +0100 Subject: [PATCH 09/28] Allow additional action buttons via `additionalActionButtons` --- src/app/components/elements/PageMetadata.tsx | 15 +++++++++------ src/app/components/elements/SaveBoardButton.tsx | 9 ++++++--- src/app/components/pages/Gameboard.tsx | 15 +++++++-------- 3 files changed, 22 insertions(+), 17 deletions(-) diff --git a/src/app/components/elements/PageMetadata.tsx b/src/app/components/elements/PageMetadata.tsx index fb754ba982..46ee533042 100644 --- a/src/app/components/elements/PageMetadata.tsx +++ b/src/app/components/elements/PageMetadata.tsx @@ -25,6 +25,7 @@ type PageMetadataProps = { children?: ReactNode; // any content-type specific metadata that may require information outside of `doc`; e.g. question completion state, event info, etc. noTitle?: boolean; // if true, any children (usually text) will be rendered in place of the title, with any action buttons (e.g. share, print, report) rendered to the side helpModalId?: string; + additionalActionButtons?: ReactNode; // pages can extend the standard action buttons with their own. they will be placed to the left of the main ones. pageContainsLLMFreeTextQuestion?: boolean; } & ( { @@ -43,14 +44,16 @@ interface ActionButtonsProps extends React.HTMLAttributes { isQuestion: boolean; helpModalId?: string; doc?: SeguePageDTO; + additionalActionButtons?: ReactNode; } -export const ActionButtons = ({location, isQuestion, helpModalId, doc, ...rest}: ActionButtonsProps) => { +export const ActionButtons = ({location, isQuestion, helpModalId, doc, additionalActionButtons, ...rest}: ActionButtonsProps) => { const deviceSize = useDeviceSize(); const anyActionButtonShown = isPhy && helpModalId || above['sm'](deviceSize) || doc?.id; return anyActionButtonShown &&
+ {additionalActionButtons} {isPhy && helpModalId && } {above['sm'](deviceSize) && <> @@ -104,7 +107,7 @@ const MetadataTitle = ({doc, title, subtitle, badges}: MetadataTitleProps) => { }; export const PageMetadata = (props: PageMetadataProps) => { - const { doc, title, subtitle, badges, children, noTitle, helpModalId, showSidebarButton, sidebarButtonText, sidebarInTitle } = props; + const { doc, title, subtitle, badges, children, noTitle, helpModalId, showSidebarButton, sidebarButtonText, sidebarInTitle, additionalActionButtons } = props; const isQuestion = doc?.type === "isaacQuestionPage"; const isConcept = doc?.type === "isaacConceptPage"; const location = useLocation(); @@ -115,16 +118,16 @@ export const PageMetadata = (props: PageMetadataProps) => { {isPhy && showSidebarButton && sidebarInTitle && below['md'](deviceSize) && }
{isPhy &&
- {actionButtonsFloat && } + {actionButtonsFloat && } {noTitle ? children : } - {!actionButtonsFloat && } + {!actionButtonsFloat && }
} {isAda &&
- {children && } + {children && } {children} - {!children && } + {!children && }
} {isPhy && !noTitle && children} diff --git a/src/app/components/elements/SaveBoardButton.tsx b/src/app/components/elements/SaveBoardButton.tsx index 1890767226..b35c318969 100644 --- a/src/app/components/elements/SaveBoardButton.tsx +++ b/src/app/components/elements/SaveBoardButton.tsx @@ -4,6 +4,7 @@ import { GameboardDTO } from "../../../IsaacApiTypes"; import classNames from "classnames"; import { ButtonProps } from "reactstrap"; import { saveGameboard, selectors, unlinkUserFromGameboard, useAppDispatch, useAppSelector } from "../../state"; +import { siteSpecific } from "../../services"; interface SaveBoardButtonProps extends ButtonProps { board: GameboardDTO; @@ -16,8 +17,8 @@ export const SaveBoardButton = (props: SaveBoardButtonProps) => { const dispatch = useAppDispatch(); const user = useAppSelector(selectors.user.loggedInOrNull); - const isLinked = useMemo(() => board.savedToCurrentUser, [board]); const [justLinked, setJustLinked] = useState(false); + const isLinked = useMemo(() => board.savedToCurrentUser || justLinked, [board, justLinked]); const linkBoard = useCallback(() => { if (!user || !board) return; @@ -45,8 +46,10 @@ export const SaveBoardButton = (props: SaveBoardButtonProps) => { return { diff --git a/src/app/components/pages/Gameboard.tsx b/src/app/components/pages/Gameboard.tsx index 06867b739c..5d655c7526 100644 --- a/src/app/components/pages/Gameboard.tsx +++ b/src/app/components/pages/Gameboard.tsx @@ -90,14 +90,13 @@ export const Gameboard = () => { undefined )} > - - {user &&
- -
} -
+ } + /> {user && isTutorOrAbove(user) From 4d4d4d7b283197c27078193c9e1ca872957b84e1 Mon Sep 17 00:00:00 2001 From: Jaycie Brown Date: Mon, 27 Apr 2026 14:33:08 +0100 Subject: [PATCH 10/28] Use new fillable icon format for star --- src/scss/common/icons.scss | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/src/scss/common/icons.scss b/src/scss/common/icons.scss index a05522ab74..c2edacfa0b 100644 --- a/src/scss/common/icons.scss +++ b/src/scss/common/icons.scss @@ -181,15 +181,13 @@ img[aria-disabled="true"] { .icon-star { @include svg-icon-layered( - '/assets/common/icons/star.svg', - '/assets/common/icons/star-fill.svg', + '/assets/common/icons/star.svg', + '/assets/common/icons/star-fill.svg', calc(var(--icon-size) * 1.2), calc(var(--icon-size) * 1.2), var(--mask-size, 60%) ); margin: calc(var(--icon-size) * (1 - 1.2)/2) !important; - &:not(.fill)::before { - background-color: transparent !important; - } + @extend %icon-fillable; } .icon-ai { From 9d6cd423b353e7016141f6a11523e6ab8ebe76d6 Mon Sep 17 00:00:00 2001 From: Jaycie Brown Date: Mon, 27 Apr 2026 15:53:12 +0100 Subject: [PATCH 11/28] Hide assign / save buttons when not permitted by role Co-authored-by: Copilot --- src/app/components/elements/SaveBoardButton.tsx | 4 +++- src/app/components/elements/cards/GameboardCard.tsx | 8 +++++--- src/app/state/slices/api/gameboards.ts | 10 +++++++++- 3 files changed, 17 insertions(+), 5 deletions(-) diff --git a/src/app/components/elements/SaveBoardButton.tsx b/src/app/components/elements/SaveBoardButton.tsx index b35c318969..033f5d6952 100644 --- a/src/app/components/elements/SaveBoardButton.tsx +++ b/src/app/components/elements/SaveBoardButton.tsx @@ -4,7 +4,7 @@ import { GameboardDTO } from "../../../IsaacApiTypes"; import classNames from "classnames"; import { ButtonProps } from "reactstrap"; import { saveGameboard, selectors, unlinkUserFromGameboard, useAppDispatch, useAppSelector } from "../../state"; -import { siteSpecific } from "../../services"; +import { isAda, isLoggedIn, isStudent, siteSpecific } from "../../services"; interface SaveBoardButtonProps extends ButtonProps { board: GameboardDTO; @@ -44,6 +44,8 @@ export const SaveBoardButton = (props: SaveBoardButtonProps) => { } }, [user, board, dispatch]); + if (!isLoggedIn(user)) return null; // anon users should not be able to save boards + if (isAda && isStudent(user)) return null; // Ada students have no page to display saved boards ( :/ ) so hide return { const {gameboard, linkLocation, children, openAssignModal, groupCount, ...rest} = props; const isSetAssignments = isDefined(groupCount); + const user = useAppSelector(selectors.user.orNull); const [showMore, setShowMore] = useState(false); const boardStagesAndDifficulties = useMemo(() => determineGameboardStagesAndDifficulties(gameboard), [gameboard]); @@ -133,9 +135,9 @@ export const GameboardCard = (props: GameboardCardProps) => { {isPhy && boardLink &&
} - + }
diff --git a/src/app/state/slices/api/gameboards.ts b/src/app/state/slices/api/gameboards.ts index 273d979325..efe6776f94 100644 --- a/src/app/state/slices/api/gameboards.ts +++ b/src/app/state/slices/api/gameboards.ts @@ -2,6 +2,7 @@ import { getValue, isAdminOrEventManager, isDefined, + isStudent, isTutorOrAbove, Item, KEY, @@ -149,12 +150,19 @@ export const unlinkUserFromGameboard = createAsyncThunk a.gameboardId === boardId) ?? []).length > 0; if (hasAssignedGroups) { From 1711da517c916ca1ad112e7ebdac63bac9a826d6 Mon Sep 17 00:00:00 2001 From: Jaycie Brown Date: Mon, 27 Apr 2026 15:53:28 +0100 Subject: [PATCH 12/28] Restore card usage info at `<=sm` --- src/app/components/elements/cards/GameboardCard.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/app/components/elements/cards/GameboardCard.tsx b/src/app/components/elements/cards/GameboardCard.tsx index d423002130..43c27e69fb 100644 --- a/src/app/components/elements/cards/GameboardCard.tsx +++ b/src/app/components/elements/cards/GameboardCard.tsx @@ -125,6 +125,8 @@ export const GameboardCard = (props: GameboardCardProps) => { + {below['xs'](deviceSize) && } +
- : "Unlink" + : "Unsave" } diff --git a/src/app/components/elements/SaveBoardButton.tsx b/src/app/components/elements/SaveBoardButton.tsx index 7011136b2a..5ebdb61aaa 100644 --- a/src/app/components/elements/SaveBoardButton.tsx +++ b/src/app/components/elements/SaveBoardButton.tsx @@ -33,8 +33,8 @@ export const SaveBoardButton = (props: SaveBoardButtonProps) => { const unlinkBoard = useCallback(() => { if (!user || !board) return; const confirmMessage = board.ownerUserId === user.id && !board.tags?.includes("ISAAC_BOARD") - ? `Are you sure you want to unlink your board '${board.title}' from your account? You'll only be able to find it again if you've set it as an assignment.` - : `Are you sure you want to unlink '${board.title}' from your account?`; + ? `Are you sure you want to unsave your board '${board.title}' from your account? You'll only be able to find it again if you've set it as an assignment.` + : `Are you sure you want to unsave '${board.title}' from your account?`; if (confirm(confirmMessage)) { setJustLinked(false); void dispatch(unlinkUserFromGameboard({ diff --git a/src/app/components/pages/SetAssignments.tsx b/src/app/components/pages/SetAssignments.tsx index bdab86a970..a8215ea68b 100644 --- a/src/app/components/pages/SetAssignments.tsx +++ b/src/app/components/pages/SetAssignments.tsx @@ -165,7 +165,7 @@ const CSTable = (props: SetAssignmentsTableProps) => { Manage Share - Unlink + Unsave ; return
From 6f88d73b034e549bbc2706954e436040131eecdb Mon Sep 17 00:00:00 2001 From: Jaycie Brown Date: Fri, 8 May 2026 11:53:58 +0100 Subject: [PATCH 24/28] Improve table view columns for MSD+SA --- src/app/components/elements/Gameboards.tsx | 4 +++- .../components/elements/cards/BoardCard.tsx | 18 +++++++++++------- 2 files changed, 14 insertions(+), 8 deletions(-) diff --git a/src/app/components/elements/Gameboards.tsx b/src/app/components/elements/Gameboards.tsx index 73c2378729..1773f1dde5 100644 --- a/src/app/components/elements/Gameboards.tsx +++ b/src/app/components/elements/Gameboards.tsx @@ -78,7 +78,9 @@ const CSTable = (props: GameboardsTableProps) => { {siteSpecific( <> - Unsave + + {boardView === BoardViews.card ? "Unsave" : "Manage"} + , <> Share diff --git a/src/app/components/elements/cards/BoardCard.tsx b/src/app/components/elements/cards/BoardCard.tsx index 4c547238b8..d22ddd5330 100644 --- a/src/app/components/elements/cards/BoardCard.tsx +++ b/src/app/components/elements/cards/BoardCard.tsx @@ -142,8 +142,6 @@ export const BoardCard = ({user, board, boardView, displayAssignmentInfo, setSel const { openAssignModal, assignees } = useSetAssignment(board); - const hasAssignedGroups = isDefined(assignees?.length) && assignees.length > 0; - const deviceSize = useDeviceSize(); const updateBoardSelection = (board: GameboardDTO, checked: boolean) => { @@ -166,7 +164,7 @@ export const BoardCard = ({user, board, boardView, displayAssignmentInfo, setSel hexagonId, boardSubjects, assignees, - toggleAssignModal: openAssignModal, + toggleAssignModal: isSetAssignments ? openAssignModal : undefined, isTable, }; @@ -211,7 +209,7 @@ export const BoardCard = ({user, board, boardView, displayAssignmentInfo, setSel {formatDate(board.lastVisited)} {isAda && @@ -251,7 +249,12 @@ export const BoardCard = ({user, board, boardView, displayAssignmentInfo, setSel } {siteSpecific( - +
+ + +
, + openAssignModal={openAssignModal} groupCount={isSetAssignments ? assignees?.length : undefined} + > {isDefined(board.creationDate) &&

@@ -343,7 +347,7 @@ export const BoardCard = ({user, board, boardView, displayAssignmentInfo, setSel {isSetAssignments && } From ff431049eb5cfbce9368e61938fada3e1387d51e Mon Sep 17 00:00:00 2001 From: Jaycie Brown Date: Fri, 8 May 2026 11:54:10 +0100 Subject: [PATCH 25/28] Fix position of icons inside scrollable tables --- src/scss/common/icons.scss | 1 + 1 file changed, 1 insertion(+) diff --git a/src/scss/common/icons.scss b/src/scss/common/icons.scss index c4b75fd88e..99f986952e 100644 --- a/src/scss/common/icons.scss +++ b/src/scss/common/icons.scss @@ -246,6 +246,7 @@ img[aria-disabled="true"] { } i.icon:not(.icon-raw) { + position: relative; background-color: var(--layered-icon-parent-bg-override, var(--icon-foreground)); @include not-reduced-motion { transition: all 0.15s ease-in-out; // mirrors BS's $btn-transition From 616dcdac6adacaa0daf7b59dcb1e9f97dff21dbf Mon Sep 17 00:00:00 2001 From: Jaycie Brown Date: Fri, 8 May 2026 11:56:37 +0100 Subject: [PATCH 26/28] Add save deck button to Manage section in SA --- src/app/components/elements/cards/BoardCard.tsx | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/app/components/elements/cards/BoardCard.tsx b/src/app/components/elements/cards/BoardCard.tsx index d22ddd5330..446bf088f4 100644 --- a/src/app/components/elements/cards/BoardCard.tsx +++ b/src/app/components/elements/cards/BoardCard.tsx @@ -208,9 +208,12 @@ export const BoardCard = ({user, board, boardView, displayAssignmentInfo, setSel {isAda && {formatBoardOwner(user, board)}} {formatDate(board.lastVisited)} - +

+ + +
{isAda &&
From 0a4b7a23a8d792c2e0152f717d395986714db2ef Mon Sep 17 00:00:00 2001 From: Jaycie Brown Date: Fri, 8 May 2026 12:21:07 +0100 Subject: [PATCH 27/28] Remove overlapping warn-on-remove-deck popup --- src/app/state/slices/api/gameboards.ts | 18 +----------------- 1 file changed, 1 insertion(+), 17 deletions(-) diff --git a/src/app/state/slices/api/gameboards.ts b/src/app/state/slices/api/gameboards.ts index efe6776f94..5f90b7db8a 100644 --- a/src/app/state/slices/api/gameboards.ts +++ b/src/app/state/slices/api/gameboards.ts @@ -140,7 +140,7 @@ export const assignGameboard = createAsyncThunk( export const unlinkUserFromGameboard = createAsyncThunk( "gameboards/deleteBoard", - async ({boardId, boardTitle}: {boardId?: string, boardTitle?: string}, {getState, dispatch, rejectWithValue}) => { + async ({boardId, boardTitle: _}: {boardId?: string, boardTitle?: string}, {getState, dispatch, rejectWithValue}) => { if (!isDefined(boardId)) { // This really shouldn't happen! dispatch(showErrorToast( @@ -162,22 +162,6 @@ export const unlinkUserFromGameboard = createAsyncThunk a.gameboardId === boardId) ?? []).length > 0; - if (hasAssignedGroups) { - if (reduxState && reduxState.user && reduxState.user.loggedIn && isAdminOrEventManager(reduxState.user)) { - if (!confirm(`Warning: You currently have groups assigned to ${boardTitle}. If you delete this your groups will still be assigned but you won't be able to unassign them or see the ${siteSpecific("question deck", "quiz")} on the ${siteSpecific("Set assignments", "Quizzes")} page.`)) { - return rejectWithValue(null); - } - } else { - dispatch(showErrorToast( - `${siteSpecific("Question deck", "Quiz")} deletion not allowed`, - `You have groups assigned to ${boardTitle}. To delete this ${siteSpecific("question deck", "quiz")}, you must unassign all groups.` - ) as any); - return rejectWithValue(null); - } - } const deleteResponse = await dispatch(gameboardApi.endpoints.unlinkUserFromGameboard.initiate(boardId)); return mutationSucceeded(deleteResponse) ? boardId : rejectWithValue(null); } else { From 160ebcd016e14b742219fc93f328260f0bc17edf Mon Sep 17 00:00:00 2001 From: Jaycie Brown Date: Fri, 8 May 2026 12:21:22 +0100 Subject: [PATCH 28/28] Show success toast on saving for better user feedback --- src/app/state/slices/api/gameboards.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/app/state/slices/api/gameboards.ts b/src/app/state/slices/api/gameboards.ts index 5f90b7db8a..94b0545fbc 100644 --- a/src/app/state/slices/api/gameboards.ts +++ b/src/app/state/slices/api/gameboards.ts @@ -1,6 +1,5 @@ import { getValue, - isAdminOrEventManager, isDefined, isStudent, isTutorOrAbove, @@ -215,9 +214,10 @@ export const saveGameboard = createAsyncThunk<{boardId: string, boardTitle?: str void navigateComponentless(`${PATHS.MY_GAMEBOARDS}#${boardId}`); } } + dispatch(showSuccessToast("Deck saved", `The deck '${boardTitle}' has successfully been saved to your account.`) as any); return {boardId, boardTitle}; } catch (e) { - dispatch(showRTKQueryErrorToastIfNeeded("Error saving gameboard", e) as any); + dispatch(showRTKQueryErrorToastIfNeeded("Error saving question deck", e) as any); return rejectWithValue(null); } }