Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
6f4f250
Prevent decks automatically saving to My QDecks on attempt
jacbn Apr 22, 2026
4177461
Move assignment-setting hooks etc inside cards
jacbn Apr 22, 2026
c14bc5a
Rename My q. decks => My saved decks
jacbn Apr 22, 2026
167d888
Add manual saving/unsaving for decks (sci-only)
jacbn Apr 23, 2026
dde2c73
Restructure board cards to better fit new content
jacbn Apr 24, 2026
7d7aec7
Share manual deck save changes with Ada
jacbn Apr 24, 2026
7f85984
Align expanded board views with board ALVIs
jacbn Apr 24, 2026
5387fc2
Fix board hex indicator position
jacbn Apr 24, 2026
0c53946
Allow additional action buttons via `additionalActionButtons`
jacbn Apr 24, 2026
18b1fcf
Merge branch 'improvement/icon-colors-css-vars' into feature/my-saved…
jacbn Apr 27, 2026
4d4d4d7
Use new fillable icon format for star
jacbn Apr 27, 2026
9d6cd42
Hide assign / save buttons when not permitted by role
jacbn Apr 27, 2026
1711da5
Restore card usage info at `<=sm`
jacbn Apr 27, 2026
7cc9891
Fix ESLint warnings
jacbn Apr 27, 2026
d427884
Wrap animation in `not-reduced-motion`
jacbn Apr 27, 2026
0653504
Move Ada student save button hiding to the board only
jacbn Apr 27, 2026
30c302e
Restore circle / hex contents on Set Assignments
jacbn Apr 27, 2026
9e636be
Fix ESLint
jacbn Apr 27, 2026
b1528f2
Rearrange SCSS files so access mixins always defined
jacbn Apr 28, 2026
1e31038
Merge branch 'main' into feature/my-saved-decks
jacbn Apr 28, 2026
c8937d1
Provide RTK user in Cypress tests with new param
jacbn Apr 28, 2026
88d35da
Add RTK user to My & Set Assignments pages
jacbn Apr 28, 2026
338f9bd
Update VRT baselines
actions-user Apr 28, 2026
0c743b7
Merge pull request #2118 from isaacphysics/vrt/feature/my-saved-decks
jacbn Apr 28, 2026
90cb196
Merge branch 'main' into feature/my-saved-decks
sjd210 May 5, 2026
fd52424
Correct comment to match new variable name
sjd210 May 5, 2026
df61a78
Fix names in `unlink => unsave` inconsistency
jacbn May 8, 2026
6f88d73
Improve table view columns for MSD+SA
jacbn May 8, 2026
ff43104
Fix position of icons inside scrollable tables
jacbn May 8, 2026
616dcda
Add save deck button to Manage section in SA
jacbn May 8, 2026
0a4b7a2
Remove overlapping warn-on-remove-deck popup
jacbn May 8, 2026
160ebcd
Show success toast on saving for better user feedback
jacbn May 8, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 8 additions & 2 deletions cypress/support/commands.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@
// }

import {mount, MountOptions} from 'cypress/react';
import { RegisteredUserDTO } from '../../src/IsaacApiTypes';

// Augment the Cypress namespace to include type definitions for
// your custom command.
Expand All @@ -46,7 +47,7 @@ declare global {
// eslint-disable-next-line @typescript-eslint/no-namespace
namespace Cypress {
interface Chainable {
mountWithStoreAndRouter(component: ReactNode, routes: string[], initialRoute?: To, mountOptions?: MountOptions): Chainable<Element>;
mountWithStoreAndRouter(component: ReactNode, routes: string[], initialRoute?: To, user?: RegisteredUserDTO, mountOptions?: MountOptions): Chainable<Element>;

openSidebar(): Chainable<JQuery<HTMLElement>>;
closeSidebar(): Chainable<JQuery<HTMLElement>>;
Expand All @@ -60,15 +61,20 @@ import {Provider} from "react-redux";
import {store} from "../../src/app/state";
import {createBrowserRouter, createRoutesFromElements, Route, To} from "react-router";
import { RouterProvider } from 'react-router-dom';
import { ACTION_TYPE } from '../../src/app/services';

Cypress.Commands.add('mountWithStoreAndRouter', (component, routes, initialRoute=routes?.[0], mountOptions) => {
Cypress.Commands.add('mountWithStoreAndRouter', (component, routes, initialRoute=routes?.[0], user, mountOptions) => {
const router = createBrowserRouter(createRoutesFromElements(<>
{routes?.length
? routes.map(route => <Route key={route} element={component} path={route} />)
: <Route path="*" element={component} />
}
</>));

if (user) {
void store.dispatch({type: ACTION_TYPE.CURRENT_USER_RESPONSE_SUCCESS, user});
}

void router.navigate(initialRoute || '/');

mount(
Expand Down
3 changes: 3 additions & 0 deletions public/assets/common/icons/star-fill.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
3 changes: 3 additions & 0 deletions public/assets/common/icons/star.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
10 changes: 7 additions & 3 deletions src/app/components/elements/Gameboards.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -78,16 +78,18 @@ const CSTable = (props: GameboardsTableProps) => {
</SortItemHeader>
{siteSpecific(
<>
<th className="text-center align-middle">Delete</th>
<th className="text-center align-middle">
{boardView === BoardViews.card ? "Unsave" : "Manage"}
</th>
</>,
<>
<th>Share</th>
<th>
{selectedBoards.length
? <Button size={"sm"} color={"link"} onClick={confirmDeleteMultipleBoards}>
Delete ({selectedBoards.length})
Unsave ({selectedBoards.length})
</Button>
: "Delete"
: "Unsave"
}
</th>
</>
Expand Down Expand Up @@ -115,6 +117,7 @@ const CSTable = (props: GameboardsTableProps) => {
boardView={boardView}
user={user}
boards={boards}
displayAssignmentInfo={false}
/>)
}
</tbody>
Expand Down Expand Up @@ -144,6 +147,7 @@ const Cards = (props: GameboardsCardsProps) => {
boardView={boardView}
user={user}
boards={boards}
displayAssignmentInfo={false}
/>
</Col>)}
</Row>}
Expand Down
15 changes: 9 additions & 6 deletions src/app/components/elements/PageMetadata.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,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;
} & (
{
Expand All @@ -45,14 +46,16 @@ interface ActionButtonsProps extends React.HTMLAttributes<HTMLDivElement> {
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 && <div {...rest} className={classNames("d-flex no-print gap-2", rest.className)}>
{additionalActionButtons}
{isPhy && isQuestion && <FeatureFlagWrapper flag={FeatureFlag.ENABLE_SCI_BOOKMARKS}>
<BookmarkButton doc={doc} />
</FeatureFlagWrapper>
Expand Down Expand Up @@ -110,7 +113,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();
Expand All @@ -121,16 +124,16 @@ export const PageMetadata = (props: PageMetadataProps) => {
{isPhy && showSidebarButton && sidebarInTitle && below['md'](deviceSize) && <SidebarButton buttonTitle={sidebarButtonText} absolute/>}
<div className="page-metadata">
{isPhy && <div className={classNames("title-action-bar", {"d-flex align-items-center": !actionButtonsFloat})}>
{actionButtonsFloat && <ActionButtons location={location} isQuestion={isQuestion} helpModalId={helpModalId} doc={doc} className="float-end ms-3 mb-2"/>}
{actionButtonsFloat && <ActionButtons location={location} isQuestion={isQuestion} helpModalId={helpModalId} doc={doc} additionalActionButtons={additionalActionButtons} className="float-end ms-3 mb-2"/>}
{noTitle ? children : <MetadataTitle doc={doc} title={title} subtitle={subtitle} badges={badges}/>}
{!actionButtonsFloat && <ActionButtons location={location} isQuestion={isQuestion} helpModalId={helpModalId} doc={doc} className={classNames("ms-auto", {"mb-auto": !noTitle && badges})}/>}
{!actionButtonsFloat && <ActionButtons location={location} isQuestion={isQuestion} helpModalId={helpModalId} doc={doc} additionalActionButtons={additionalActionButtons} className={classNames("ms-auto", {"mb-auto": !noTitle && badges})}/>}
</div>}

{isAda && <div className={classNames("title-action-bar", {"d-flex align-items-end": !children})}>
{children && <ActionButtons location={location} isQuestion={isQuestion} helpModalId={helpModalId} doc={doc} className="float-end ms-3 mb-3"/>}
{children && <ActionButtons location={location} isQuestion={isQuestion} helpModalId={helpModalId} doc={doc} additionalActionButtons={additionalActionButtons} className="float-end ms-3 mb-3"/>}
<TagStack doc={doc} className={classNames({"mb-3": children, "d-flex align-items-end": !children})}/>
{children}
{!children && <ActionButtons location={location} isQuestion={isQuestion} helpModalId={helpModalId} doc={doc} className="ms-auto"/>}
{!children && <ActionButtons location={location} isQuestion={isQuestion} helpModalId={helpModalId} doc={doc} additionalActionButtons={additionalActionButtons} className="ms-auto"/>}
</div>}

{isPhy && !noTitle && children}
Expand Down
66 changes: 66 additions & 0 deletions src/app/components/elements/SaveBoardButton.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import React, { useCallback, useMemo, useState } from "react";
import { IconButton } from "./AffixButton";
import { GameboardDTO } from "../../../IsaacApiTypes";
import classNames from "classnames";
import { ButtonProps } from "reactstrap";
import { saveGameboard, selectors, unlinkUserFromGameboard, useAppDispatch, useAppSelector } from "../../state";
import { isLoggedIn, 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
}

export const SaveBoardButton = (props: SaveBoardButtonProps) => {
const { board, size, className, ...rest } = props;

const dispatch = useAppDispatch();
const user = useAppSelector(selectors.user.loggedInOrNull);

const [justLinked, setJustLinked] = useState(false);
const isLinked = useMemo(() => board.savedToCurrentUser || justLinked, [board, justLinked]);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This seems to be an API issue, but I'll leave a comment here while it's on my mind and it can be worked on later like the other known issues. This board.savedToCurrentUser is not behaving as it should for gameboard pages themselves. The board appears as saved if and only if the user has attempted any questions on it (as would've previously created a link).

Using the button does successfully save/unsave boards if in the right state to do so, but that state does not reflect the actual saved-ness of the board and whether it shows up in "My saved decks".

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah, thanks for spotting this. There's a card for savedToCurrentUser not working for My Assignments and book pages, but this seems to be a third case.


const linkBoard = useCallback(() => {
if (!user || !board) return;
setJustLinked(true);
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 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)) {
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This has strange interactions with the existing deck deletion code at https://github.com/isaacphysics/isaac-react-app/blob/main/src/app/state/slices/api/gameboards.ts#L162.

  • An admin may get two popups in a row when unlinking a deck that has been assigned - it feels like we can combine these into one.
  • A non-admin gets an error after they have confirmed unlinking in this same scenario. If we want to maintain requiring a teacher to be linked to a deck while it is assigned, we shouldn't have that confirmation box appear in the scenario and should just fail early.
    • That said, maybe we shouldn't be enforcing it in this new system anyway? It was already inconsistent for groups with multiple managers (since only one manager would need to be linked to and set the assignment for it to apply to that whole group), and since saving is now a much more separate/manually controlled action, it seems more consistent to give them full control over saving.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agree with your last point; to me, the spirit of these changes are to make saving a completely separate interaction to creating, assigning, etc. You should be free to unsave a deck if you're done with it, regardless of whether it is assigned, so long as it is still accessible from somewhere. Right now, this last point is not met, since I have not yet done the changes to Set Assignments; but when that shows all assigned decks, this should work as expected.

I've removed the whole chunk of code around stopping this; it's obviously a little weird right now with not being able to access assigned ones, but this should be resolved soon.

setJustLinked(false);
void dispatch(unlinkUserFromGameboard({
boardId: board.id ?? "",
boardTitle: board.title
}));
}
}, [user, board, dispatch]);

if (!isLoggedIn(user)) return null; // anon users should not be able to save boards

return <IconButton
icon={{
name: classNames("icon-star", siteSpecific("icon-color-black-hoverable", undefined), { "fill": isLinked, "anim-star-select": justLinked }),
color: siteSpecific(undefined, props.color === "solid" ? "white" : "primary")
}}
className={classNames(className, "w-max-content h-max-content action-button", {"icon-button-sm": size === "sm"})}
title={isLinked ? "Unsave board" : "Save board"}
onClick={(e) => {
e.preventDefault();
if (isLinked) {
unlinkBoard();
} else {
linkBoard();
}
}}
{...rest}
/>;
};
2 changes: 1 addition & 1 deletion src/app/components/elements/StudentDashboard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -178,7 +178,7 @@ const MyIsaacPanel = ({assignmentsCount, quizzesCount}: MyIsaacPanelProps) => {
<h4>More in My Isaac</h4>
<div className="d-flex flex-column">
<Link to={PATHS.MY_GAMEBOARDS} className="panel-my-isaac-link">
My question decks
My saved decks
</Link>
<Link to="/assignments" className="panel-my-isaac-link">
My assignments
Expand Down
Loading
Loading