Skip to content

Commit 4e5a99c

Browse files
authored
Merge pull request #1402 from isaacphysics/redesign/practice-quizzes
Redesign: practice tests
2 parents 084cbd5 + fb428be commit 4e5a99c

12 files changed

+329
-106
lines changed

src/app/components/elements/layout/SidebarLayout.tsx

Lines changed: 137 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,12 @@
1-
import React, { ChangeEvent, RefObject, useEffect, useRef, useState } from "react";
1+
import React, { ChangeEvent, Dispatch, RefObject, SetStateAction, useEffect, useRef, useState } from "react";
22
import { Col, ColProps, RowProps, Input, Offcanvas, OffcanvasBody, OffcanvasHeader, Row, DropdownItem, DropdownMenu, DropdownToggle, UncontrolledDropdown, Form, Label } from "reactstrap";
33
import partition from "lodash/partition";
44
import classNames from "classnames";
55
import { AssignmentDTO, ContentSummaryDTO, GameboardDTO, GameboardItem, IsaacBookIndexPageDTO, IsaacConceptPageDTO, QuestionDTO, QuizAssignmentDTO, QuizAttemptDTO, RegisteredUserDTO, Stage } from "../../../../IsaacApiTypes";
6-
import { above, ACCOUNT_TAB, ACCOUNT_TABS, AUDIENCE_DISPLAY_FIELDS, below, BOARD_ORDER_NAMES, BoardCompletions, BoardCreators, BoardLimit, BoardSubjects, BoardViews, confirmThen, determineAudienceViews, EventStageMap, EventStatusFilter, EventTypeFilter, filterAssignmentsByStatus, filterAudienceViewsByProperties, getDistinctAssignmentGroups, getDistinctAssignmentSetters, getHumanContext, getThemeFromContextAndTags, HUMAN_STAGES, ifKeyIsEnter, isAda, isDefined, PHY_NAV_SUBJECTS, isTeacherOrAbove, QuizStatus, siteSpecific, TAG_ID, tags, STAGE, useDeviceSize, LearningStage, HUMAN_SUBJECTS, ArrayElement, isFullyDefinedContext, isSingleStageContext, Item, stageLabelMap, extractTeacherName, determineGameboardSubjects, PATHS, getQuestionPlaceholder } from "../../../services";
6+
import { above, ACCOUNT_TAB, ACCOUNT_TABS, AUDIENCE_DISPLAY_FIELDS, below, BOARD_ORDER_NAMES, BoardCompletions, BoardCreators, BoardLimit, BoardSubjects, BoardViews, confirmThen, determineAudienceViews, EventStageMap,
7+
EventStatusFilter, EventTypeFilter, filterAssignmentsByStatus, filterAudienceViewsByProperties, getDistinctAssignmentGroups, getDistinctAssignmentSetters, getHumanContext, getThemeFromContextAndTags, HUMAN_STAGES,
8+
ifKeyIsEnter, isAda, isDefined, PHY_NAV_SUBJECTS, isTeacherOrAbove, QuizStatus, siteSpecific, TAG_ID, tags, STAGE, useDeviceSize, LearningStage, HUMAN_SUBJECTS, ArrayElement, isFullyDefinedContext, isSingleStageContext,
9+
Item, stageLabelMap, extractTeacherName, determineGameboardSubjects, PATHS, getQuestionPlaceholder, getFilteredStageOptions } from "../../../services";
710
import { StageAndDifficultySummaryIcons } from "../StageAndDifficultySummaryIcons";
811
import { selectors, useAppSelector, useGetQuizAssignmentsAssignedToMeQuery } from "../../../state";
912
import { Link, useHistory, useLocation } from "react-router-dom";
@@ -541,9 +544,132 @@ export const QuestionFinderSidebar = (props: QuestionFinderSidebarProps) => {
541544
</ContentSidebar>;
542545
};
543546

544-
export const PracticeQuizzesSidebar = (props: SidebarProps) => {
545-
// TODO
546-
return <ContentSidebar {...props}/>;
547+
interface PracticeQuizzesSidebarProps extends SidebarProps {
548+
filterText: string;
549+
setFilterText: Dispatch<SetStateAction<string>>;
550+
filterTags?: Tag[];
551+
setFilterTags: Dispatch<SetStateAction<Tag[]>>;
552+
tagCounts: Record<string, number>;
553+
filterStages?: Stage[];
554+
setFilterStages: Dispatch<SetStateAction<Stage[] | undefined>>;
555+
stageCounts: Record<string, number>;
556+
}
557+
558+
export const PracticeQuizzesSidebar = (props: PracticeQuizzesSidebarProps) => {
559+
const { filterText, setFilterText, filterTags, setFilterTags, tagCounts, filterStages, setFilterStages, stageCounts, ...rest } = props;
560+
const pageContext = useAppSelector(selectors.pageContext.context);
561+
const fields = pageContext?.subject ? tags.getDirectDescendents(pageContext.subject as TAG_ID) : [];
562+
563+
const updateFilterTags = (tag: Tag) => {
564+
if (filterTags?.includes(tag)) {
565+
setFilterTags(filterTags.filter(t => t !== tag));
566+
}
567+
else {
568+
setFilterTags([...(filterTags ?? []), tag]);
569+
}
570+
};
571+
572+
const updateFilterStages = (stage: Stage) => {
573+
if (filterStages?.includes(stage)) {
574+
setFilterStages(filterStages.filter(s => s !== stage));
575+
} else {
576+
setFilterStages([...(filterStages ?? []), stage]);
577+
}
578+
};
579+
580+
// Clear stage filters on subject change, since previous stages may not be visible to deselect
581+
useEffect(() => {
582+
setFilterStages(undefined);
583+
}, [filterTags]);
584+
585+
return <ContentSidebar {...rest}>
586+
<div className="section-divider"/>
587+
<h5>Search practice tests</h5>
588+
<Input type="search" placeholder="e.g. Challenge" value={filterText} className="search--filter-input my-3"
589+
onChange={(e: ChangeEvent<HTMLInputElement>) => setFilterText(e.target.value)} />
590+
591+
{!pageContext?.subject && Object.keys(PHY_NAV_SUBJECTS).filter(s => tagCounts[s] > 0).length > 0 && <>
592+
<div className="section-divider"/>
593+
<h5>Filter by subject</h5>
594+
<ul>
595+
{Object.keys(PHY_NAV_SUBJECTS).filter(s => tagCounts[s] > 0).map((subject, i) => {
596+
const subjectTag = tags.getById(subject as TAG_ID);
597+
const descendentTags = tags.getDirectDescendents(subjectTag.id);
598+
const isSelected = filterTags?.includes(subjectTag) || descendentTags.some(tag => filterTags?.includes(tag));
599+
const isPartial = descendentTags.some(tag => filterTags?.includes(tag)) && descendentTags.some(tag => !filterTags?.includes(tag));
600+
return <li key={i} className={classNames("ps-2", {"checkbox-region": isSelected})}>
601+
<FilterCheckbox
602+
checkboxStyle="button" color="theme" data-bs-theme={subject} tag={subjectTag} conceptFilters={filterTags as Tag[]}
603+
setConceptFilters={setFilterTags} tagCounts={tagCounts} dependentTags={descendentTags} incompatibleTags={descendentTags}
604+
partiallySelected={descendentTags.some(tag => filterTags?.includes(tag))}
605+
className={classNames({"icon-checkbox-off": !isSelected, "icon icon-checkbox-partial-alt": isSelected && isPartial, "icon-checkbox-selected": isSelected && !isPartial})}
606+
/>
607+
{isSelected && <ul className="ms-3 ps-2">
608+
{descendentTags.filter(tag => tagCounts[tag.id] > 0)
609+
.map((tag, j) => <li key={j}>
610+
<FilterCheckbox
611+
checkboxStyle="button" color="theme" bsSize="sm" data-bs-theme={subject} tag={tag} conceptFilters={filterTags as Tag[]}
612+
setConceptFilters={setFilterTags} tagCounts={tagCounts} incompatibleTags={[subjectTag]}
613+
/>
614+
</li>)}
615+
</ul>}
616+
</li>;
617+
})}
618+
</ul>
619+
</>}
620+
621+
{pageContext?.subject && fields.filter(tag => tagCounts[tag.id] > 0).length > 0 && <>
622+
<div className="section-divider"/>
623+
<h5>Filter by topic</h5>
624+
<ul>
625+
{fields.filter(tag => tagCounts[tag.id] > 0)
626+
.map((tag, j) => <li key={j} >
627+
<StyledTabPicker checkboxTitle={tag.title} checked={filterTags?.includes(tag)}
628+
count={tagCounts[tag.id]} onInputChange={() => updateFilterTags(tag)}/>
629+
</li>)}
630+
</ul>
631+
</>}
632+
633+
{!isSingleStageContext(pageContext) && getFilteredStageOptions().filter(s => stageCounts[s.label] > 0).length > 0 && <>
634+
<div className="section-divider"/>
635+
<h5>Filter by stage</h5>
636+
<ul>
637+
{getFilteredStageOptions().filter(s => stageCounts[s.label] > 0).map((stage, i) =>
638+
<li key={i}>
639+
<StyledCheckbox checked={filterStages?.includes(stage.value)}
640+
label={<>{stage.label} {tagCounts && <span className="text-muted">({stageCounts[stage.label]})</span>}</>}
641+
color="theme" data-bs-theme={filterTags?.length === 1 ? filterTags[0].id : undefined}
642+
onChange={() => {updateFilterStages(stage.value);}}/>
643+
</li>)}
644+
</ul>
645+
</>}
646+
647+
{isFullyDefinedContext(pageContext) && <>
648+
<div className="section-divider"/>
649+
<div className="sidebar-help">
650+
<p>The practice tests shown here have been filtered to only show those that are relevant to {getHumanContext(pageContext)}.</p>
651+
<p>If you want to explore our full range of practice tests, you can view the main practice tests page:</p>
652+
<AffixButton size="md" color="keyline" tag={Link} to="/practice_tests" affix={{
653+
affix: "icon-right",
654+
position: "suffix",
655+
type: "icon"
656+
}}>
657+
Browse all practice tests
658+
</AffixButton>
659+
</div>
660+
</>}
661+
<div className="section-divider"/>
662+
<div className="sidebar-help">
663+
<p>You can see all of the tests that you have in progress or have completed in your My Isaac:</p>
664+
<AffixButton size="md" color="keyline" tag={Link} to="/tests" affix={{
665+
affix: "icon-right",
666+
position: "suffix",
667+
type: "icon"
668+
}}>
669+
My tests
670+
</AffixButton>
671+
</div>
672+
</ContentSidebar>;
547673
};
548674

549675
export const LessonsAndRevisionSidebar = (props: SidebarProps) => {
@@ -1188,10 +1314,12 @@ export const MyQuizzesSidebar = (props: MyQuizzesSidebarProps) => {
11881314
{statusOptions.map(state => <QuizStatusCheckbox
11891315
key={state} status={state} count={undefined} statusFilter={quizStatusFilter} setStatusFilter={setQuizStatusFilter}
11901316
/>)}
1191-
<h5 className="my-3">Filter by assigner</h5>
1192-
<Input type="select" onChange={e => setQuizCreatorFilter(e.target.value)}>
1193-
{["All", ...getDistinctAssignmentSetters(quizzes)].map(setter => <option key={setter} value={setter}>{setter}</option>)}
1194-
</Input>
1317+
{activeTab === 1 && <>
1318+
<h5 className="my-3">Filter by assigner</h5>
1319+
<Input type="select" onChange={e => setQuizCreatorFilter(e.target.value)}>
1320+
{["All", ...getDistinctAssignmentSetters(quizzes)].map(setter => <option key={setter} value={setter}>{setter}</option>)}
1321+
</Input>
1322+
</>}
11951323
<div className="section-divider mt-4"/>
11961324
<h5 className="mb-3">Display</h5>
11971325
<StyledDropdown value={displayMode} onChange={() => setDisplayMode(d => d === "table" ? "cards" : "table")}>

src/app/components/elements/list-groups/AbstractListViewItem.tsx

Lines changed: 13 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,11 @@
11
import { Link } from "react-router-dom";
2-
import React from "react";
2+
import React, { ReactNode } from "react";
33
import { StageAndDifficultySummaryIcons } from "../StageAndDifficultySummaryIcons";
44
import { ViewingContext} from "../../../../IsaacAppTypes";
55
import classNames from "classnames";
6-
import { Button, Col, ListGroupItem, ListGroupItemProps, Row } from "reactstrap";
7-
import { Spacer } from "../Spacer";
6+
import { Button, Col, ListGroupItem, ListGroupItemProps } from "reactstrap";
87
import { CompletionState } from "../../../../IsaacApiTypes";
9-
import { below, isPhy, Subject, useDeviceSize } from "../../../services";
8+
import { below, isPhy, siteSpecific, Subject, useDeviceSize } from "../../../services";
109
import { PhyHexIcon } from "../svg/PhyHexIcon";
1110
import { TitleIconProps } from "../PageTitle";
1211
import { Markup } from "../markup";
@@ -44,14 +43,12 @@ const LinkTags = ({linkTags}: {linkTags: {tag: string, url?: string}[];}) => {
4443
</>;
4544
};
4645

47-
const QuizLinks = (props: React.HTMLAttributes<HTMLSpanElement> & {previewQuizUrl: string, quizButton: JSX.Element}) => {
46+
const QuizLinks = (props: React.HTMLAttributes<HTMLSpanElement> & {previewQuizUrl?: string, quizButton?: ReactNode}) => {
4847
const { previewQuizUrl, quizButton, ...rest } = props;
49-
return <span {...rest} className={classNames(rest.className, "d-flex")}>
50-
<Spacer/>
51-
<Button to={previewQuizUrl} color="keyline" tag={Link} className="set-quiz-button-md">
52-
Preview
53-
</Button>
54-
<span style={{minWidth: "20px"}}/>
48+
return <span {...rest} className={classNames(rest.className, "d-flex justify-content-end gap-3")}>
49+
{previewQuizUrl && <Button to={previewQuizUrl} color={siteSpecific("keyline", "secondary")} tag={Link} className="set-quiz-button-md">
50+
{previewQuizUrl.includes("/preview/") ? "Preview" : "View test"}
51+
</Button>}
5552
{quizButton}
5653
</span>;
5754
};
@@ -62,7 +59,7 @@ export interface ListViewTagProps {
6259
}
6360

6461
export interface AbstractListViewItemProps extends ListGroupItemProps {
65-
title: string;
62+
title?: string;
6663
icon?: TitleIconProps;
6764
subject?: Subject;
6865
subtitle?: string;
@@ -82,7 +79,7 @@ export interface AbstractListViewItemProps extends ListGroupItemProps {
8279

8380
export const AbstractListViewItem = ({icon, title, subject, subtitle, breadcrumb, status, tags, supersededBy, linkTags, quizTag, url, audienceViews, previewQuizUrl, quizButton, isCard, fullWidth, ...rest}: AbstractListViewItemProps) => {
8481
const deviceSize = useDeviceSize();
85-
const isQuiz: boolean = (previewQuizUrl && quizButton) ? true : false;
82+
const isQuiz: boolean = !!(previewQuizUrl || quizButton);
8683

8784
fullWidth = fullWidth || below["sm"](deviceSize) || ((status || audienceViews || previewQuizUrl || quizButton) ? false : true);
8885
const colWidths = fullWidth ? [12,12,12,12,12] : isQuiz ? [12,6,6,6,6] : [12,8,7,6,7];
@@ -96,7 +93,7 @@ export const AbstractListViewItem = ({icon, title, subject, subtitle, breadcrumb
9693
</div>
9794
<div className="align-content-center">
9895
<div className="d-flex">
99-
<span className="question-link-title"><Markup encoding="latex">{title}</Markup></span>
96+
<span className={classNames("link-title", {"question-link-title": isPhy || !isQuiz})}><Markup encoding="latex">{title}</Markup></span>
10097
{quizTag && <span className="quiz-level-1-tag ms-sm-2">{quizTag}</span>}
10198
{isPhy && <div className="d-flex flex-column justify-self-end">
10299
{supersededBy && <a
@@ -124,7 +121,7 @@ export const AbstractListViewItem = ({icon, title, subject, subtitle, breadcrumb
124121
{linkTags && <div className="d-flex py-3 flex-wrap">
125122
<LinkTags linkTags={linkTags}/>
126123
</div>}
127-
{previewQuizUrl && quizButton && fullWidth && <div className="d-flex d-md-none align-items-center">
124+
{isQuiz && fullWidth && <div className="d-flex d-md-none align-items-center">
128125
<QuizLinks previewQuizUrl={previewQuizUrl} quizButton={quizButton}/>
129126
</div>}
130127
</div>
@@ -137,7 +134,7 @@ export const AbstractListViewItem = ({icon, title, subject, subtitle, breadcrumb
137134
{audienceViews && <Col md={4} lg={5} xl={4} xxl={3} className="d-none d-md-flex justify-content-end">
138135
<StageAndDifficultySummaryIcons audienceViews={audienceViews} stack spacerWidth={5} className={classNames({"list-view-border": audienceViews.length > 0})}/>
139136
</Col>}
140-
{previewQuizUrl && quizButton && <Col md={6} className="d-none d-md-flex align-items-center justify-content-end">
137+
{isQuiz && <Col md={6} className="d-none d-md-flex align-items-center justify-content-end">
141138
<QuizLinks previewQuizUrl={previewQuizUrl} quizButton={quizButton}/>
142139
</Col>}
143140
</>

src/app/components/elements/list-groups/ContentSummaryListGroupItem.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -167,7 +167,7 @@ export const ContentSummaryListGroupItem = ({item, search, showBreadcrumb, noCar
167167
<div className={classNames("flex-fill", {"py-3 pe-3 align-content-center": isAda, "d-flex": isAda && !stack, "d-md-flex": isPhy})}>
168168
<div className={"align-self-center " + titleClasses}>
169169
<div className="d-flex">
170-
<Markup encoding={"latex"} className={classNames( "question-link-title", {"text-theme": isPhy})}>
170+
<Markup encoding={"latex"} className={classNames( "link-title question-link-title", {"text-theme": isPhy})}>
171171
{title ?? ""}
172172
</Markup>
173173
{isPhy && typeLabel && <span className={"small text-muted align-self-end d-none d-md-inline ms-2 mb-1"}>

0 commit comments

Comments
 (0)