diff --git a/cypress/support/commands.tsx b/cypress/support/commands.tsx index 1ba823c6c5..ee3e974871 100644 --- a/cypress/support/commands.tsx +++ b/cypress/support/commands.tsx @@ -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. @@ -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; + mountWithStoreAndRouter(component: ReactNode, routes: string[], initialRoute?: To, user?: RegisteredUserDTO, mountOptions?: MountOptions): Chainable; openSidebar(): Chainable>; closeSidebar(): Chainable>; @@ -60,8 +61,9 @@ 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 => ) @@ -69,6 +71,10 @@ Cypress.Commands.add('mountWithStoreAndRouter', (component, routes, initialRoute } )); + if (user) { + void store.dispatch({type: ACTION_TYPE.CURRENT_USER_RESPONSE_SUCCESS, user}); + } + void router.navigate(initialRoute || '/'); mount( 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/Gameboards.tsx b/src/app/components/elements/Gameboards.tsx index 91a40bc51e..1773f1dde5 100644 --- a/src/app/components/elements/Gameboards.tsx +++ b/src/app/components/elements/Gameboards.tsx @@ -78,16 +78,18 @@ const CSTable = (props: GameboardsTableProps) => { {siteSpecific( <> - Delete + + {boardView === BoardViews.card ? "Unsave" : "Manage"} + , <> Share {selectedBoards.length ? - : "Delete" + : "Unsave" } @@ -115,6 +117,7 @@ const CSTable = (props: GameboardsTableProps) => { boardView={boardView} user={user} boards={boards} + displayAssignmentInfo={false} />) } @@ -144,6 +147,7 @@ const Cards = (props: GameboardsCardsProps) => { boardView={boardView} user={user} boards={boards} + displayAssignmentInfo={false} /> )} } diff --git a/src/app/components/elements/PageMetadata.tsx b/src/app/components/elements/PageMetadata.tsx index 8e516238e1..f6a32c37fd 100644 --- a/src/app/components/elements/PageMetadata.tsx +++ b/src/app/components/elements/PageMetadata.tsx @@ -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; } & ( { @@ -45,14 +46,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 && isQuestion && @@ -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(); @@ -121,16 +124,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 new file mode 100644 index 0000000000..5ebdb61aaa --- /dev/null +++ b/src/app/components/elements/SaveBoardButton.tsx @@ -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]); + + 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)) { + 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 { + e.preventDefault(); + if (isLinked) { + unlinkBoard(); + } else { + linkBoard(); + } + }} + {...rest} + />; +}; 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/elements/cards/BoardCard.tsx b/src/app/components/elements/cards/BoardCard.tsx index 3dfac0c40b..446bf088f4 100644 --- a/src/app/components/elements/cards/BoardCard.tsx +++ b/src/app/components/elements/cards/BoardCard.tsx @@ -6,7 +6,6 @@ import { formatBoardOwner, generateGameboardSubjectHexagons, isAda, - isAdminOrEventManager, isDefined, isPhy, PATHS, @@ -14,7 +13,6 @@ import { stageLabelMap, useDeviceSize } from "../../../services"; -import {showErrorToast, unlinkUserFromGameboard, useAppDispatch} from "../../../state"; import {GameboardDTO, RegisteredUserDTO} from "../../../../IsaacApiTypes"; import {Circle} from "../svg/Circle"; import classNames from "classnames"; @@ -37,8 +35,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 { SaveBoardButton } from "../SaveBoardButton"; interface HexagonGroupsButtonProps { @@ -128,23 +127,20 @@ 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 deviceSize = useDeviceSize(); @@ -157,21 +153,6 @@ export const BoardCard = ({user, board, boardView, assignees, toggleAssignModal, } }; - 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); @@ -183,7 +164,7 @@ export const BoardCard = ({user, board, boardView, assignees, toggleAssignModal, hexagonId, boardSubjects, assignees, - toggleAssignModal, + toggleAssignModal: isSetAssignments ? openAssignModal : undefined, isTable, }; @@ -227,9 +208,12 @@ export const BoardCard = ({user, board, boardView, assignees, toggleAssignModal, {isAda && {formatBoardOwner(user, board)}} {formatDate(board.lastVisited)} - +
+ + +
{isAda &&
@@ -237,7 +221,7 @@ export const BoardCard = ({user, board, boardView, assignees, toggleAssignModal,
} {isAda && - + } : @@ -266,27 +250,37 @@ export const BoardCard = ({user, board, boardView, assignees, toggleAssignModal,
} - {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 + {isDefined(board.creationDate) &&

@@ -299,6 +293,8 @@ export const BoardCard = ({user, board, boardView, assignees, toggleAssignModal, , + + // ada @@ -352,9 +348,9 @@ 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..43c27e69fb 100644 --- a/src/app/components/elements/cards/GameboardCard.tsx +++ b/src/app/components/elements/cards/GameboardCard.tsx @@ -1,32 +1,78 @@ 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 { 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, isTutorOrAbove } from "../../../services"; import { HexIcon } from "../svg/HexIcon"; import { Link } from "react-router-dom"; import classNames from "classnames"; import { Spacer } from "../Spacer"; import { ShareLink } from "../ShareLink"; +import { SaveBoardButton } from "../SaveBoardButton"; +import { selectors, useAppSelector } from "../../../state"; export enum GameboardLinkLocation { // where on the card can the user click to navigate to the gameboard Card, Title } +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; + isSetAssignments?: boolean; +} + +// "Attempted/Correct" percentages or "Assigned to X groups" +const CardUsageInfo = ({ gameboard, groupCount, isSetAssignments, className, ...rest }: CardUsageInfoProps) => { + return

+ {!isSetAssignments + ? <> + + + + : <> + + + } +
; +}; 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, 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]); @@ -42,8 +88,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}` @@ -51,13 +95,14 @@ export const GameboardCard = (props: GameboardCardProps) => { const card =
- -
-
+ +
+
{generateGameboardSubjectHexagons(boardSubjects)}
+ {gameboard?.contents && }

@@ -71,66 +116,34 @@ export const GameboardCard = (props: GameboardCardProps) => { {boardSubjects.map((subject) => {HUMAN_SUBJECTS[subject]})}

}
+ {!below['xs'](deviceSize) && }
{children} - - {above[isSetAssignments ? 'sm' : 'md'](deviceSize) && } + - -
- {!isSetAssignments - ? <> - - - - : <> - - - } -
- {isSetAssignments - ? above['md'](deviceSize) &&
- {isPhy && boardLink &&
- -
} - -
- : boardLink &&
- -
- } - - {isSetAssignments && !above['md'](deviceSize) && - - } - - {!above[isSetAssignments ? 'sm' : 'md'](deviceSize) && + +
+ {isPhy && gameboard && } + {isPhy && boardLink &&
+ +
} + {isTutorOrAbove(user) && } - - +
+
+ + {/* collapsed info */} @@ -173,14 +186,10 @@ export const GameboardCard = (props: GameboardCardProps) => { if (gameboard && linkLocation === GameboardLinkLocation.Card && boardLink) { return {card} - {onDelete && } ; } else { return
{card} - {onDelete && }
; } }; diff --git a/src/app/components/elements/list-groups/AbstractListViewItem.tsx b/src/app/components/elements/list-groups/AbstractListViewItem.tsx index 0dd3716fd9..5a3c1084c3 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, ListGroupItemProps } from "reactstrap"; +import { Button, Col, ListGroupItem, ListGroupItemProps } from "reactstrap"; import { CompletionState, GameboardDTO } from "../../../../IsaacApiTypes"; import { above, below, isAda, isDefined, isLoggedIn, 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"; import { useBookmarks } from "../../../services/bookmarks"; import { FeatureFlag, useFeatureFlag } from "../../../services/featureFlag"; @@ -54,16 +56,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; @@ -101,7 +93,8 @@ const GameboardAssign = ({board}: {board?: GameboardDTO}) => { const [ getAssignments ] = useLazyGetMySetAssignmentsQuery(); const [ unassignBoard ] = useUnassignGameboardMutation(); - return
@@ -258,8 +251,10 @@ export const AbstractListViewItem = ({title, icon, subject, subtitle, breadcrumb {isPhy && isItem && stackedLayout && typedProps.status && typedProps.status !== CompletionState.ALL_CORRECT && } - {isGameboard && stackedLayout && isTeacherOrAbove(user) &&
- + {isGameboard && stackedLayout && isDefined(typedProps.board) &&
+ {/* note order flipped relative to non-stackedLayout to keep Assign at highest priority visually */} + {isTeacherOrAbove(user) && } +
} {isItem && typedProps.linkTags &&
@@ -275,9 +270,10 @@ export const AbstractListViewItem = ({title, icon, subject, subtitle, breadcrumb {(isItem || isBuilder) && typedProps.audienceViews &&
0})}>
} - {isGameboard && isTeacherOrAbove(user) && - - } + {isGameboard && typedProps.board &&
+ + {isTeacherOrAbove(user) && } +
} {isQuiz && } diff --git a/src/app/components/pages/Gameboard.tsx b/src/app/components/pages/Gameboard.tsx index 754d5b8078..67e1ca7a5f 100644 --- a/src/app/components/pages/Gameboard.tsx +++ b/src/app/components/pages/Gameboard.tsx @@ -24,7 +24,8 @@ import { PATHS, SEARCH_RESULT_TYPE, showWildcard, - siteSpecific + siteSpecific, + isStudent } from "../../services"; import {Navigate, useLocation} from "react-router"; import classNames from "classnames"; @@ -35,6 +36,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 +91,16 @@ export const Gameboard = () => { undefined )} > - + + } + /> {user && isTutorOrAbove(user) @@ -111,7 +122,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; 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 = @@ -176,7 +165,7 @@ const CSTable = (props: SetAssignmentsTableProps) => { Manage Share - Delete + Unsave ; return
@@ -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/components/site/phy/NavigationMenuPhy.tsx b/src/app/components/site/phy/NavigationMenuPhy.tsx index 1160bdfc72..9f04c68d1a 100644 --- a/src/app/components/site/phy/NavigationMenuPhy.tsx +++ b/src/app/components/site/phy/NavigationMenuPhy.tsx @@ -311,7 +311,7 @@ const ContentNavProfile = ({toggleMenu}: {toggleMenu: () => void}) => { {isTutorOrAbove(user) &&
STUDENT
}
    - My question decks + My saved decks diff --git a/src/app/services/questions.ts b/src/app/services/questions.ts index 2930fc9b61..054f19c215 100644 --- a/src/app/services/questions.ts +++ b/src/app/services/questions.ts @@ -1,8 +1,8 @@ import React, {ContextType, lazy} from "react"; import {AppQuestionDTO, InlineContext, IsaacQuestionProps, PageContextState, ValidatedChoice} from "../../IsaacAppTypes"; import {ChoiceDTO, CompletionState, ContentDTO, ContentSummaryDTO, GameboardDTO} from "../../IsaacApiTypes"; -import {DOCUMENT_TYPE, REVERSE_GREEK_LETTERS_MAP_PYTHON, REVERSE_GREEK_LETTERS_MAP_LATEX, persistence, KEY, trackEvent, isLoggedIn, isTeacherPending, wasTodayUTC, PHY_NAV_SUBJECTS, isSingleStageContext, isFullyDefinedContext, isDefined} from './'; -import {attemptQuestion, saveGameboard, selectors, setCurrentAttempt, useAppDispatch, useAppSelector} from "../state"; +import {DOCUMENT_TYPE, REVERSE_GREEK_LETTERS_MAP_PYTHON, REVERSE_GREEK_LETTERS_MAP_LATEX, persistence, KEY, trackEvent, wasTodayUTC, PHY_NAV_SUBJECTS, isSingleStageContext, isFullyDefinedContext, isDefined} from './'; +import {attemptQuestion, selectors, setCurrentAttempt, useAppDispatch, useAppSelector} from "../state"; import {Immutable} from "immer"; import _flattenDeep from 'lodash/flatMapDeep'; import { Inequality, makeInequality } from "inequality"; @@ -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(); 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", 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 }; +}; diff --git a/src/app/state/slices/api/gameboards.ts b/src/app/state/slices/api/gameboards.ts index 273d979325..94b0545fbc 100644 --- a/src/app/state/slices/api/gameboards.ts +++ b/src/app/state/slices/api/gameboards.ts @@ -1,7 +1,7 @@ import { getValue, - isAdminOrEventManager, isDefined, + isStudent, isTutorOrAbove, Item, KEY, @@ -139,7 +139,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( @@ -149,27 +149,18 @@ 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 { @@ -223,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); } } diff --git a/src/scss/common/accessibility.scss b/src/scss/common/_accessibility.scss similarity index 100% rename from src/scss/common/accessibility.scss rename to src/scss/common/_accessibility.scss diff --git a/src/scss/common/animation.scss b/src/scss/common/animation.scss new file mode 100644 index 0000000000..bea6229f74 --- /dev/null +++ b/src/scss/common/animation.scss @@ -0,0 +1,17 @@ +@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 { + @include not-reduced-motion { + animation: star-select 0.3s ease-out; + } +} diff --git a/src/scss/common/icons.scss b/src/scss/common/icons.scss index 473d6933a5..99f986952e 100644 --- a/src/scss/common/icons.scss +++ b/src/scss/common/icons.scss @@ -179,6 +179,17 @@ 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; + + @extend %icon-fillable; +} + .icon-ai { @include svg-icon('/assets/common/icons/ai.svg', var(--icon-size), var(--icon-size), var(--mask-size, 60%)); } @@ -235,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 diff --git a/src/scss/cs/accessibility.scss b/src/scss/cs/_accessibility.scss similarity index 100% rename from src/scss/cs/accessibility.scss rename to src/scss/cs/_accessibility.scss diff --git a/src/scss/cs/isaac.scss b/src/scss/cs/isaac.scss index a6d0db0d75..c4a866a060 100644 --- a/src/scss/cs/isaac.scss +++ b/src/scss/cs/isaac.scss @@ -383,10 +383,11 @@ $theme-colors-border-subtle: map-merge($theme-colors-border-subtle, $custom-colo // Utility @import "../common/mixins"; @import "../common/_utils"; +@import "_accessibility"; @import "../common/transitions"; +@import "../common/animation"; // Theming -@import "accessibility"; @import "color-theme"; // Atoms diff --git a/src/scss/phy/assignments.scss b/src/scss/phy/assignments.scss index d28a4c3d26..ee88a2d30e 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/boards.scss b/src/scss/phy/boards.scss index a54671374c..a7b933501d 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; diff --git a/src/scss/phy/gameboard.scss b/src/scss/phy/gameboard.scss index eba0d40b65..527bcb7f15 100644 --- a/src/scss/phy/gameboard.scss +++ b/src/scss/phy/gameboard.scss @@ -76,3 +76,17 @@ button.btn.delete-button { .bg-wildcard { background-color: $color-misc-pale-yellow; } + +%hex-status-indicator { + position: absolute; + top: 2px; + z-index: 7; + > i { + display: inline-block; + } +} + +.board-card-status-indicator { + @extend %hex-status-indicator; + right: 0px; +} diff --git a/src/scss/phy/isaac.scss b/src/scss/phy/isaac.scss index 9f82847b22..c74f108878 100644 --- a/src/scss/phy/isaac.scss +++ b/src/scss/phy/isaac.scss @@ -233,11 +233,12 @@ $theme-colors-border-subtle: map-merge($theme-colors-border-subtle, $custom-colo // Utility @import "../common/mixins"; @import "../common/_utils"; +@import "../common/_accessibility"; @import "../common/transitions"; +@import "../common/animation"; // theming @import "color-theme"; -@import "../common/accessibility"; // Atoms @import "fonts"; diff --git a/src/scss/phy/list-groups.scss b/src/scss/phy/list-groups.scss index 972a7494b9..d92c2697ee 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 { diff --git a/src/test/pages/MyAssignments.cy.tsx b/src/test/pages/MyAssignments.cy.tsx index 9cf100ece4..5a9fc4dc70 100644 --- a/src/test/pages/MyAssignments.cy.tsx +++ b/src/test/pages/MyAssignments.cy.tsx @@ -6,7 +6,7 @@ import {PATHS} from "../../app/services"; it('My Assignments should have no visual regressions', () => { // @ts-ignore - cy.mountWithStoreAndRouter(, [PATHS.MY_ASSIGNMENTS]); + cy.mountWithStoreAndRouter(, [PATHS.MY_ASSIGNMENTS], PATHS.MY_ASSIGNMENTS, mockUser); cy.get('[data-testid="loading"]').should('not.exist'); cy.matchImage(); -}); \ No newline at end of file +}); diff --git a/src/test/pages/MyGameboards.cy.tsx b/src/test/pages/MyGameboards.cy.tsx index 52f14ba98b..9e59870820 100644 --- a/src/test/pages/MyGameboards.cy.tsx +++ b/src/test/pages/MyGameboards.cy.tsx @@ -5,7 +5,7 @@ import {MyGameboards} from "../../app/components/pages/MyGameboards"; describe("My Gameboards", () => { it('should have no visual regressions in table view', () => { - cy.mountWithStoreAndRouter(, [PATHS.MY_GAMEBOARDS]); + cy.mountWithStoreAndRouter(, [PATHS.MY_GAMEBOARDS], PATHS.MY_GAMEBOARDS, mockUser); if (isPhy) cy.openSidebar(); cy.get('[data-testid="display-select"]').select("Table View"); if (isPhy) cy.closeSidebar(); @@ -13,7 +13,7 @@ describe("My Gameboards", () => { cy.matchImage(); }); it('should have no visual regressions in card view', () => { - cy.mountWithStoreAndRouter(, [PATHS.MY_GAMEBOARDS]); + cy.mountWithStoreAndRouter(, [PATHS.MY_GAMEBOARDS], PATHS.MY_GAMEBOARDS, mockUser); if (isPhy) cy.openSidebar(); cy.get('[data-testid="display-select"]').select("Card View"); cy.get('[data-testid="limit-select"]').select("6"); diff --git a/src/test/pages/SetAssignments.cy.tsx b/src/test/pages/SetAssignments.cy.tsx index e89e9b1a07..a43680c491 100644 --- a/src/test/pages/SetAssignments.cy.tsx +++ b/src/test/pages/SetAssignments.cy.tsx @@ -5,7 +5,7 @@ import {SetAssignments} from "../../app/components/pages/SetAssignments"; it('Set Assignments should have no visual regressions', () => { // @ts-ignore - cy.mountWithStoreAndRouter(, [PATHS.SET_ASSIGNMENTS]); + cy.mountWithStoreAndRouter(, [PATHS.SET_ASSIGNMENTS], PATHS.SET_ASSIGNMENTS, mockUser); cy.get('[data-testid="loading"]').should('not.exist'); cy.matchImage(); -}); \ No newline at end of file +}); diff --git a/src/test/pages/__image_snapshots__/ada/My Gameboards should have no visual regressions in card view #0.png b/src/test/pages/__image_snapshots__/ada/My Gameboards should have no visual regressions in card view #0.png index eb77b61b82..b239f10004 100644 Binary files a/src/test/pages/__image_snapshots__/ada/My Gameboards should have no visual regressions in card view #0.png and b/src/test/pages/__image_snapshots__/ada/My Gameboards should have no visual regressions in card view #0.png differ diff --git a/src/test/pages/__image_snapshots__/ada/Set Assignments should have no visual regressions #0.png b/src/test/pages/__image_snapshots__/ada/Set Assignments should have no visual regressions #0.png index 8844517e99..0d13d95cf1 100644 Binary files a/src/test/pages/__image_snapshots__/ada/Set Assignments should have no visual regressions #0.png and b/src/test/pages/__image_snapshots__/ada/Set Assignments should have no visual regressions #0.png differ