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/IsaacAppTypes.tsx b/src/IsaacAppTypes.tsx index a4f9be7184..af21655490 100644 --- a/src/IsaacAppTypes.tsx +++ b/src/IsaacAppTypes.tsx @@ -10,6 +10,7 @@ import { Difficulty, GameboardDTO, GameboardItem, + IAssignmentLike, ItemDTO, QuestionDTO, QuestionValidationResponseDTO, @@ -421,6 +422,13 @@ export interface ValidAssignmentWithListingDate extends AssignmentDTO { listingDate: Date; } +export interface ValidWorkWithListingDate extends IAssignmentLike { + groupId: number; + additionalManagerPrivileges: boolean; + id: number; + listingDate: Date; +} + export interface AssignmentProgressPageSettings { colourBlind: boolean; setColourBlind: (colourBlind: boolean) => void; @@ -486,6 +494,16 @@ export const ExpandableParentContext = React.createContext(false); export const ConfidenceContext = React.createContext<{recordConfidence: boolean}>({recordConfidence: false}); export const AssignmentProgressPageSettingsContext = React.createContext(undefined); export const GameboardContext = React.createContext(undefined); + +export const ManageAssignmentsContext = React.createContext<{ + groupsById: {[id: number]: AppGroup | undefined}; + workByGroup: {[id: number]: {boards?: IAssignmentLike[], tests?: IAssignmentLike[]} | undefined}; + groups: AppGroup[]; + collapsed: boolean; + setCollapsed: (b: boolean) => void; + viewBy: "startDate" | "dueDate"; +}>({groupsById: {}, workByGroup: {}, groups: [], collapsed: false, setCollapsed: () => {}, viewBy: "startDate"}); + export const AssignmentScheduleContext = React.createContext<{ boardsById: {[id: string]: GameboardDTO | undefined}; groupsById: {[id: number]: AppGroup | undefined}; @@ -498,6 +516,7 @@ export const AssignmentScheduleContext = React.createContext<{ setCollapsed: (b: boolean) => void; viewBy: "startDate" | "dueDate"; }>({boardsById: {}, groupsById: {}, groupFilter: {}, boardIdsByGroupId: {}, groups: [], gameboards: [], openAssignmentModal: () => {}, collapsed: false, setCollapsed: () => {}, viewBy: "startDate"}); + export const SidebarContext = React.createContext<{sidebarPresent: boolean} | undefined>(undefined); export const ContentSidebarContext = React.createContext<{ toggle: () => void; close: () => void; } | undefined>(undefined); 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/ManageAssignedCards.tsx b/src/app/components/elements/ManageAssignedCards.tsx new file mode 100644 index 0000000000..667fe8583b --- /dev/null +++ b/src/app/components/elements/ManageAssignedCards.tsx @@ -0,0 +1,82 @@ +import React from "react"; +import { Row, Col } from "reactstrap"; +import { AssignmentDTO, QuizAssignmentDTO } from "../../../IsaacApiTypes"; +import { isDefined, isOverdue, useManageQuizAssignments } from "../../services"; +import { useManageAssignment } from "../../services/setAssignment"; +import { GameboardCard, GameboardLinkLocation } from "./cards/GameboardCard"; +import { getFriendlyDaysUntil } from "./DateString"; +import { TestCard } from "./cards/TestCard"; + +// this is similar to MyAssignmentsContents/AssignmentCard, but: +// - GameboardCard.usageDisplay is undefined, so no completion / group statistics are shown in the top right. +// - inside the card's children, does not highlight past deadlines. +// - GameboardCard.allowManaging is set +export const ManageAssignmentCard = ({assignment}: {assignment: AssignmentDTO}) => { + const assignmentStartDate = assignment.scheduledStartDate ?? assignment.creationDate; + + const { openAssignModal, unassign } = useManageAssignment(assignment); + + return + + + {isDefined(assignment.groupName) && +

Set to {assignment.groupName}

+ } + {isDefined(assignmentStartDate) && +

+ Assigned {getFriendlyDaysUntil(assignmentStartDate)} +

+ } + {isDefined(assignment.dueDate) && isDefined(assignment.gameboard) &&

+ Due {getFriendlyDaysUntil(assignment.dueDate)} + {isOverdue(assignment) && (passed)} +

} + +
+ + {assignment.notes &&

Notes: {assignment.notes}

} +
; +}; + +export const ManageTestCard = ({quizAssignment}: {quizAssignment: QuizAssignmentDTO}) => { + + const { cancel, openExtendDueDateModal, openAssignModal } = useManageQuizAssignments(); + + return openAssignModal(quizAssignment)} + cancel={() => cancel(quizAssignment)} + extendDueDate={() => openExtendDueDateModal(quizAssignment)} + allowManaging + > + + + {/* // TODO groupName is not defined anywhere in the quiz summary – can we add this in the API? */} + {/* {isDefined(quizAssignment.quizSummary?.groupName) && +

Set to {quizAssignment.quizSummary?.groupName}

+ } */} + {isDefined(quizAssignment?.scheduledStartDate) || isDefined(quizAssignment?.creationDate) && +

+ Assigned {getFriendlyDaysUntil(quizAssignment.scheduledStartDate || quizAssignment.creationDate)} +

+ } + {isDefined(quizAssignment?.dueDate) &&

+ Due {getFriendlyDaysUntil(quizAssignment.dueDate)} + {isOverdue(quizAssignment) && (passed)} +

} + +
+
; +}; diff --git a/src/app/components/elements/MyAssignmentsContents.tsx b/src/app/components/elements/MyAssignmentsContents.tsx index f9e23da0a3..52ac2c145d 100644 --- a/src/app/components/elements/MyAssignmentsContents.tsx +++ b/src/app/components/elements/MyAssignmentsContents.tsx @@ -27,7 +27,7 @@ const CSCircle = ({label, percentage}: {percentage: number | unknown, label: str const PhyAssignmentCard = ({assignment}: {assignment: AssignmentDTO}) => { const assignmentStartDate = assignment.scheduledStartDate ?? assignment.creationDate; - return + return {isDefined(assignmentStartDate) && 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/ShareLink.tsx b/src/app/components/elements/ShareLink.tsx index b252903a18..9d8778b2a7 100644 --- a/src/app/components/elements/ShareLink.tsx +++ b/src/app/components/elements/ShareLink.tsx @@ -64,7 +64,7 @@ export const ShareLink = ({linkUrl, reducedWidthLink, gameboardId, clickAwayClos
} {

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 e798d2dc93..d73c2a22d2 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 && - + } : @@ -268,27 +252,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) &&

@@ -300,6 +294,8 @@ export const BoardCard = ({user, board, boardView, assignees, toggleAssignModal, , + + // ada @@ -354,9 +350,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 72f37afb0b..92432f79ba 100644 --- a/src/app/components/elements/cards/GameboardCard.tsx +++ b/src/app/components/elements/cards/GameboardCard.tsx @@ -1,35 +1,106 @@ import React, { useMemo, useState } from "react"; import { AssignmentDTO, 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, siteSpecific, TODAY } 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"; import { SupersededDeprecatedBoardContentWarning } from "../../navigation/SupersededDeprecatedWarning"; import { FeatureFlag, useFeatureFlag } from "../../../services/featureFlag"; +import { getFriendlyDaysUntil } from "../DateString"; 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 GameboardCardProps extends React.HTMLAttributes { +type GameboardCardUsageDisplay = { + type: "correctness"; +} | { + type: "group"; + groupCount: number; +} | { + type: "progressLink"; + assignment: AssignmentDTO; +} +interface CardUsageInfoProps extends React.HTMLAttributes { gameboard?: GameboardDTO; - linkLocation?: GameboardLinkLocation; - onDelete?: () => void; // if this exists, a delete button will be shown calling this function - assignment?: AssignmentDTO; // if this exists, the link will point to the assignment page instead - setAssignmentsDetails?: { - groupCount?: number; - toggleAssignModal?: () => void; - } + usageDisplay?: GameboardCardUsageDisplay; } +// "Attempted/Correct" percentages or "Assigned to X groups" +const CardUsageInfo = ({ gameboard, usageDisplay, className, ...rest }: CardUsageInfoProps) => { + return

+ {usageDisplay?.type === "correctness" && <> + + + } + {usageDisplay?.type === "group" && <> + + } + {usageDisplay?.type === "progressLink" && <> + {isDefined(usageDisplay.assignment.scheduledStartDate) && usageDisplay.assignment.scheduledStartDate >= TODAY() + ?
+ + Begins  + {getFriendlyDaysUntil(usageDisplay.assignment.scheduledStartDate)} + +
+ : + View group progress + (opens in new tab) +