diff --git a/public/assets/common/icons/completed.svg b/public/assets/common/icons/completed.svg new file mode 100644 index 0000000000..26372d00fb --- /dev/null +++ b/public/assets/common/icons/completed.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/assets/common/icons/filter-icon.svg b/public/assets/common/icons/filter-icon.svg new file mode 100644 index 0000000000..4b8e5d7984 --- /dev/null +++ b/public/assets/common/icons/filter-icon.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/assets/common/icons/incorrect.svg b/public/assets/common/icons/incorrect.svg new file mode 100644 index 0000000000..7322443cbf --- /dev/null +++ b/public/assets/common/icons/incorrect.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/assets/common/icons/not-started.svg b/public/assets/common/icons/not-started.svg new file mode 100644 index 0000000000..1fcd42970d --- /dev/null +++ b/public/assets/common/icons/not-started.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/IsaacAppTypes.tsx b/src/IsaacAppTypes.tsx index 61fb7a4158..b14565168e 100644 --- a/src/IsaacAppTypes.tsx +++ b/src/IsaacAppTypes.tsx @@ -561,6 +561,7 @@ export interface QuestionSearchQuery { stages?: string; difficulties?: string; examBoards?: string; + questionCategories?: string; fasttrack?: boolean; hideCompleted?: boolean; startIndex?: number; diff --git a/src/app/components/elements/CollapsibleList.tsx b/src/app/components/elements/CollapsibleList.tsx new file mode 100644 index 0000000000..fbed44923d --- /dev/null +++ b/src/app/components/elements/CollapsibleList.tsx @@ -0,0 +1,59 @@ +import React, { useLayoutEffect, useRef, useState } from "react"; +import { Col, Row } from "reactstrap"; +import { Spacer } from "./Spacer"; +import { FilterCount } from "./svg/FilterCount"; +import classNames from "classnames"; + +export interface CollapsibleListProps { + title?: string; + asSubList?: boolean; + expanded: boolean; + toggle: () => void; + numberSelected?: number; + children?: React.ReactNode; + className?: string; +} + +export const CollapsibleList = (props: CollapsibleListProps) => { + const {expanded, toggle} = props; + const [expandedHeight, setExpandedHeight] = useState(0); + const listRef = useRef(null); + const headRef = useRef(null); + + useLayoutEffect(() => { + if (!listRef.current) return; + setExpandedHeight(listRef.current.clientHeight); + }, [listRef.current]); + + useLayoutEffect(() => { + if (expanded) { + setExpandedHeight(listRef?.current ? [...listRef.current.children].map(c => + c.getAttribute("data-targetHeight") ? parseInt(c.getAttribute("data-targetHeight") as string) : c.clientHeight + ).reduce((a, b) => a + b, 0) : 0); + } + }, [expanded, props.children]); + + const title = props.title && props.asSubList ? props.title : {props.title}; + + return +
+ +
+ + +
+ {props.children} +
+ +
+ ; +}; diff --git a/src/app/components/elements/StageAndDifficultySummaryIcons.tsx b/src/app/components/elements/StageAndDifficultySummaryIcons.tsx index 74e1fcac41..7a632fba9e 100644 --- a/src/app/components/elements/StageAndDifficultySummaryIcons.tsx +++ b/src/app/components/elements/StageAndDifficultySummaryIcons.tsx @@ -1,21 +1,52 @@ import React from "react"; import classNames from "classnames"; -import {isAda, isPhy, STAGE, stageLabelMap} from "../../services"; +import {simpleDifficultyLabelMap, siteSpecific, STAGE, stageLabelMap} from "../../services"; import {DifficultyIcons} from "./svg/DifficultyIcons"; import {ViewingContext} from "../../../IsaacAppTypes"; +import { Difficulty } from "../../../IsaacApiTypes"; -export function StageAndDifficultySummaryIcons({audienceViews, className}: {audienceViews: ViewingContext[], className?: string}) { - // FIXME find a better way than hiding the whole thing on mobile - return
- {audienceViews.map((view, i) => -
0), "d-flex d-md-block": isPhy, "d-block text-center mx-2 my-1": isAda})}> - {view.stage && view.stage !== STAGE.ALL &&
- {stageLabelMap[view.stage]} -
} - {view.difficulty &&
- -
} -
) - } -
+export function StageAndDifficultySummaryIcons({audienceViews, className, stack}: { + audienceViews: ViewingContext[], + className?: string, + stack?: boolean, +}) { + const difficulties: Difficulty[] = audienceViews.map(v => v.difficulty).filter(v => v !== undefined); + return siteSpecific( +
+ {audienceViews.map((view, i) => +
0})}> + {view.stage && view.stage !== STAGE.ALL &&
+ {stageLabelMap[view.stage]} +
} + {view.difficulty &&
+ +
} +
) + } +
, +
+ { + difficulties.every((v, _i, arr) => v === arr[0]) + ?
+ {difficulties.length > 0 && <> +
+ {simpleDifficultyLabelMap[difficulties[0]]} +
+
+ +
+ } +
+ : audienceViews.map(view => +
+ {view.stage && view.stage !== STAGE.ALL &&
+ {stageLabelMap[view.stage]} +
} + {view.difficulty &&
+ +
} +
) + } +
, + ); } diff --git a/src/app/components/elements/inputs/StyledCheckbox.tsx b/src/app/components/elements/inputs/StyledCheckbox.tsx index 88d2176be9..b72efb6c60 100644 --- a/src/app/components/elements/inputs/StyledCheckbox.tsx +++ b/src/app/components/elements/inputs/StyledCheckbox.tsx @@ -8,29 +8,32 @@ import classNames from "classnames"; // A custom checkbox, dealing with mouse and keyboard input. Pass `onChange((e : ChangeEvent) => void)`, `checked: bool`, and `label: Element` as required as props to use. export const StyledCheckbox = (props : InputProps) => { + + const {label, ignoreLabelHover, className, ...rest} = props; + const [checked, setChecked] = useState(props.checked ?? false); const id = useMemo(() => {return (props.id ?? "") + "-" + v4();}, [props.id]); const onCheckChange = (e: React.ChangeEvent) => { props.onChange && props.onChange(e); setChecked(e.target.checked); }; - + // if `checked` is changed externally, reflect this here useEffect(() => { setChecked(props.checked ?? false); }, [props.checked]); return
-
+
{checked &&
} - onCheckChange(e)} // If the user toggles with a keyboard, this does not change the state of the checkbox, so we need to do it manually (with modification to `target` // as this is a keyboard event, not a change event). We also prevent default to avoid submitting the outer form. onKeyDown={(e) => ifKeyIsEnter(() => {onCheckChange({...e, target: {...e.currentTarget, checked: !e.currentTarget.checked}}); e.preventDefault();})(e)} />
- {props.label &&
; -}; \ No newline at end of file +}; diff --git a/src/app/components/elements/list-groups/ContentSummaryListGroupItem.tsx b/src/app/components/elements/list-groups/ContentSummaryListGroupItem.tsx index 5c351a9c87..ae148f2050 100644 --- a/src/app/components/elements/list-groups/ContentSummaryListGroupItem.tsx +++ b/src/app/components/elements/list-groups/ContentSummaryListGroupItem.tsx @@ -1,6 +1,7 @@ import {ContentSummaryDTO} from "../../../../IsaacApiTypes"; import { AUDIENCE_DISPLAY_FIELDS, + below, determineAudienceViews, DOCUMENT_TYPE, documentTypePathPrefix, @@ -27,8 +28,14 @@ import {ShortcutResponse} from "../../../../IsaacAppTypes"; import {Markup} from "../markup"; import classNames from "classnames"; import {ListGroup, ListGroupItem, UncontrolledTooltip} from "reactstrap"; +import { CSSModule } from "reactstrap/types/lib/utils"; -export const ContentSummaryListGroupItem = ({item, search, displayTopicTitle}: {item: ShortcutResponse; search?: string; displayTopicTitle?: boolean}) => { +export const ContentSummaryListGroupItem = ({item, search, displayTopicTitle, noCaret}: { + item: ShortcutResponse; + search?: string; + displayTopicTitle?: boolean; + noCaret?: boolean; +}) => { const componentId = useRef(uuid_v4().slice(0, 4)).current; const userContext = useUserViewingContext(); const user = useAppSelector(selectors.user.orNull); @@ -39,6 +46,7 @@ export const ContentSummaryListGroupItem = ({item, search, displayTopicTitle}: { let itemClasses = "p-0 content-summary-link "; itemClasses += isContentsIntendedAudience ? "bg-transparent " : "de-emphasised "; + let stack = false; let title = item.title; let titleClasses = "content-summary-link-title flex-grow-1 "; const itemSubject = tags.getSpecifiedTag(TAG_LEVEL.subject, item.tags as TAG_ID[]); @@ -47,17 +55,16 @@ export const ContentSummaryListGroupItem = ({item, search, displayTopicTitle}: { } const iconClasses = `search-item-icon ${itemSubject?.id}-fill`; const hierarchyTags = tags.getByIdsAsHierarchy((item.tags || []) as TAG_ID[]) - .filter((t, i) => !isAda || i !== 0); // CS always has Computer Science at the top level + .filter((_t, i) => !isAda || i !== 0); // CS always has Computer Science at the top level - // FIXME "correct" never actually exists on questions here... const questionIconLabel = item.correct ? "Completed question icon" : "Question icon"; const questionIcon = siteSpecific( item.correct ? : , item.correct ? - {questionIconLabel}/ : - {questionIconLabel}/ + {questionIconLabel}/ : + {questionIconLabel}/ ); const deviceSize = useDeviceSize(); @@ -81,9 +88,7 @@ export const ContentSummaryListGroupItem = ({item, search, displayTopicTitle}: { linkDestination = `/${documentTypePathPrefix[DOCUMENT_TYPE.QUESTION]}/${item.id}`; icon = questionIcon; audienceViews = filterAudienceViewsByProperties(determineAudienceViews(item.audience), AUDIENCE_DISPLAY_FIELDS); - if (isAda) { - typeLabel = "Question"; - } + stack = below["md"](deviceSize); break; case (DOCUMENT_TYPE.CONCEPT): linkDestination = `/${documentTypePathPrefix[DOCUMENT_TYPE.CONCEPT]}/${item.id}`; @@ -100,7 +105,7 @@ export const ContentSummaryListGroupItem = ({item, search, displayTopicTitle}: { case (DOCUMENT_TYPE.TOPIC_SUMMARY): linkDestination = `/${documentTypePathPrefix[DOCUMENT_TYPE.TOPIC_SUMMARY]}/${item.id?.slice("topic_summary_".length)}`; icon = Topic summary page icon; - typeLabel = "Topic" + typeLabel = "Topic"; break; case (DOCUMENT_TYPE.GENERIC): linkDestination = `/${documentTypePathPrefix[DOCUMENT_TYPE.GENERIC]}/${item.id}`; @@ -126,7 +131,7 @@ export const ContentSummaryListGroupItem = ({item, search, displayTopicTitle}: {
)} -
+
@@ -153,23 +158,27 @@ export const ContentSummaryListGroupItem = ({item, search, displayTopicTitle}: { {`This content has ${notRelevantMessage(userContext)}.`}
} - {audienceViews && audienceViews.length > 0 && } + {audienceViews && audienceViews.length > 0 && }
- {isAda &&
{"Go
} + {isAda && !noCaret &&
{"Go
} ; }; -export const LinkToContentSummaryList = ({items, search, displayTopicTitle, ...rest}: { +export const LinkToContentSummaryList = ({items, search, displayTopicTitle, noCaret, ...rest}: { items: ContentSummaryDTO[]; search?: string; displayTopicTitle?: boolean; + noCaret?: boolean; tag?: React.ElementType; flush?: boolean; className?: string; - cssModule?: any; + cssModule?: CSSModule; }) => { return - {items.map(item => )} + {items.map(item => )} ; }; diff --git a/src/app/components/elements/markup/portals/InlineDropZones.tsx b/src/app/components/elements/markup/portals/InlineDropZones.tsx index 9335da6a27..b4282a1ca5 100644 --- a/src/app/components/elements/markup/portals/InlineDropZones.tsx +++ b/src/app/components/elements/markup/portals/InlineDropZones.tsx @@ -110,7 +110,7 @@ function InlineDropRegion({id, index, emptyWidth, emptyHeight, rootElement}: {id {isDefined(isCorrect) &&
{isCorrect ? "✔" : "✘"}
} - {!item && expand dropdown} + {!item && expand dropdown}
diff --git a/src/app/components/elements/modals/QuestionFinderDifficultyModal.tsx b/src/app/components/elements/modals/QuestionFinderDifficultyModal.tsx new file mode 100644 index 0000000000..47045b354a --- /dev/null +++ b/src/app/components/elements/modals/QuestionFinderDifficultyModal.tsx @@ -0,0 +1,48 @@ +import React from "react"; +import { Col } from "reactstrap"; +import { siteSpecific } from "../../../services"; +import { closeActiveModal, store } from "../../../state"; +import { ActiveModal } from "../../../../IsaacAppTypes"; + +const QuestionFinderDifficultyModal = () => { + return + {siteSpecific(

+ Practice questions let you directly apply one idea. +

    +
  • + P1 covers revision of a previous stage or topics near the beginning of a course +
  • +
  • + P3 covers later topics. +
  • +
+ Challenge questions are solved by combining multiple concepts and creativity. +
    +
  • + C1 can be attempted near the beginning of your course +
  • +
  • + C3 require more creativity and could be attempted later in a course. +
  • +
+

,

+ We split our questions into two categories: +

    +
  • + Practice questions focus on one concept +
  • +
  • + Challenge questions combine multiple concepts +
  • +
+

)} + ; +}; + +export const questionFinderDifficultyModal = () : ActiveModal => { + return { + closeAction: () => store.dispatch(closeActiveModal()), + title: siteSpecific("Difficulty Levels", "What do the difficulty levels mean?"), + body: , + }; +}; diff --git a/src/app/components/elements/panels/QuestionFinderFilterPanel.tsx b/src/app/components/elements/panels/QuestionFinderFilterPanel.tsx new file mode 100644 index 0000000000..973b5a5876 --- /dev/null +++ b/src/app/components/elements/panels/QuestionFinderFilterPanel.tsx @@ -0,0 +1,438 @@ +import React, { Dispatch, SetStateAction, useReducer, useState } from "react"; +import { Button, Card, CardBody, CardHeader, Col } from "reactstrap"; +import { CollapsibleList } from "../CollapsibleList"; +import { + above, + below, + getFilteredExamBoardOptions, + getFilteredStageOptions, + groupTagSelectionsByParent, + isAda, + isPhy, + Item, + SIMPLE_DIFFICULTY_ITEM_OPTIONS, + siteSpecific, + STAGE, + TAG_ID, + tags, + useDeviceSize +} from "../../../services"; +import { Difficulty, ExamBoard } from "../../../../IsaacApiTypes"; +import { QuestionStatus } from "../../pages/QuestionFinder"; +import classNames from "classnames"; +import { StyledCheckbox } from "../inputs/StyledCheckbox"; +import { DifficultyIcons } from "../svg/DifficultyIcons"; +import { GroupBase } from "react-select"; +import { HierarchyFilterHexagonal, Tier } from "../svg/HierarchyFilter"; +import { openActiveModal, useAppDispatch } from "../../../state"; +import { questionFinderDifficultyModal } from "../modals/QuestionFinderDifficultyModal"; +import { Spacer } from "../Spacer"; + + +const bookOptions: Item[] = [ + {value: "phys_book_step_up", label: "Step Up to GCSE Physics"}, + {value: "phys_book_gcse", label: "GCSE Physics"}, + {value: "physics_skills_19", label: "A Level Physics (3rd Edition)"}, + {value: "physics_linking_concepts", label: "Linking Concepts in Pre-Uni Physics"}, + {value: "maths_book_gcse", label: "GCSE Maths"}, + {value: "maths_book", label: "Pre-Uni Maths"}, + {value: "chemistry_16", label: "A-Level Physical Chemistry"} +]; + +const sublistDelimiter = " >>> "; +type TopLevelListsState = { + stage: {state: boolean, subList: boolean}, + examBoard: {state: boolean, subList: boolean}, + topics: {state: boolean, subList: boolean}, + difficulty: {state: boolean, subList: boolean}, + books: {state: boolean, subList: boolean}, + questionStatus: {state: boolean, subList: boolean}, +}; +type OpenListsState = TopLevelListsState & { + [sublistId: string]: {state: boolean, subList: boolean} +}; +type ListStateActions = {type: "toggle", id: string, focus: boolean} + | {type: "expandAll", expand: boolean}; +function listStateReducer(state: OpenListsState, action: ListStateActions): OpenListsState { + switch (action.type) { + case "toggle": + return action.focus + ? Object.fromEntries(Object.keys(state).map( + (title) => [ + title, + { + // Close all lists except this one + state: action.id === title && !(state[action.id]?.state) + // But if this is a sublist don't change top-level lists + || (state[action.id]?.subList + && !(state[title]?.subList) + && state[title]?.state), + subList: state[title]?.subList + } + ]) + ) as OpenListsState + : {...state, [action.id]: { + state: !(state[action.id]?.state), + subList: state[action.id]?.subList + }}; + case "expandAll": + return Object.fromEntries(Object.keys(state).map( + (title) => [ + title, + { + state: action.expand && !(state[title]?.subList), + subList: state[title]?.subList + } + ])) as OpenListsState; + default: + return state; + } +} +function initialiseListState(tags: GroupBase>[]): OpenListsState { + const subListState = Object.fromEntries( + tags.filter(tag => tag.label) + .map(tag => [ + `topics ${sublistDelimiter} ${tag.label}`, + {state: false, subList: true} + ]) + ); + return { + ...subListState, + stage: {state: true, subList: false}, + examBoard: {state: false, subList: false}, + topics: {state: false, subList: false}, + difficulty: {state: false, subList: false}, + books: {state: false, subList: false}, + questionStatus: {state: false, subList: false} + }; +} + +const listTitles: { [field in keyof TopLevelListsState]: string } = { + stage: "Stage", + examBoard: "Exam board", + topics: "Topics", + difficulty: siteSpecific("Difficulty", "Question difficulty"), + books: "Book", + questionStatus: siteSpecific("Status", "Question status") +}; + +interface QuestionFinderFilterPanelProps { + searchDifficulties: Difficulty[]; setSearchDifficulties: Dispatch>; + searchTopics: string[], setSearchTopics: Dispatch>; + searchStages: STAGE[], setSearchStages: Dispatch>; + searchExamBoards: ExamBoard[], setSearchExamBoards: Dispatch>; + searchStatuses: QuestionStatus, setSearchStatuses: Dispatch>; + searchBooks: string[], setSearchBooks: Dispatch>; + excludeBooks: boolean, setExcludeBooks: Dispatch>; + tiers: Tier[], choices: Item[][], selections: Item[][], setTierSelection: (tierIndex: number) => React.Dispatch[]>>, + applyFilters: () => void; clearFilters: () => void; + filtersSelected: boolean; searchDisabled: boolean; +} +export function QuestionFinderFilterPanel(props: QuestionFinderFilterPanelProps) { + const { + searchDifficulties, setSearchDifficulties, + searchTopics, setSearchTopics, + searchStages, setSearchStages, + searchExamBoards, setSearchExamBoards, + searchStatuses, setSearchStatuses, + searchBooks, setSearchBooks, + excludeBooks, setExcludeBooks, + tiers, choices, selections, setTierSelection, + applyFilters, clearFilters, filtersSelected, searchDisabled + } = props; + const groupBaseTagOptions: GroupBase>[] = tags.allSubcategoryTags.map(groupTagSelectionsByParent); + + const [listState, listStateDispatch] = useReducer(listStateReducer, groupBaseTagOptions, initialiseListState); + const deviceSize = useDeviceSize(); + const dispatch = useAppDispatch(); + + const [filtersVisible, setFiltersVisible] = useState(above["lg"](deviceSize)); + + const handleFilterPanelExpansion = (e? : React.MouseEvent) => { + e?.stopPropagation(); + if (below["md"](deviceSize)) { + listStateDispatch({type: "expandAll", expand: false}); + setFiltersVisible(p => !p); + } else { + listStateDispatch({ + type: "expandAll", + expand: !Object.values(listState).some(v => v.state && !v.subList + )}); + } + }; + + return + { + // the filters panel can only be collapsed when it is not a sidebar + // (changing screen size after collapsing does not re-expand it but the options become visible) + if (below["md"](deviceSize)) handleFilterPanelExpansion(e); + }}> +
+ Filter + Filter by +
+ + {filtersSelected &&
+ +
} + {below["md"](deviceSize) &&
+ +
} +
+ + listStateDispatch({type: "toggle", id: "stage", focus: below["md"](deviceSize)})} + numberSelected={searchStages.length} + > + {getFilteredStageOptions().map((stage, index) => ( +
+ setSearchStages(s => s.includes(stage.value) ? s.filter(v => v !== stage.value) : [...s, stage.value])} + label={{stage.label}} + /> +
+ ))} +
+ {isAda && listStateDispatch({type: "toggle", id: "examBoard", focus: below["md"](deviceSize)})} + numberSelected={searchExamBoards.length} + > + {getFilteredExamBoardOptions({byStages: searchStages}).map((board, index) => ( +
+ setSearchExamBoards(s => s.includes(board.value) ? s.filter(v => v !== board.value) : [...s, board.value])} + label={{board.label}} + /> +
+ ))} +
} + listStateDispatch({type: "toggle", id: "topics", focus: below["md"](deviceSize)})} + numberSelected={siteSpecific( + // Find the last non-zero tier in the tree + // FIXME: Use `filter` and `at` when Safari supports it + selections.map(tier => tier.length) + .reverse() + .find(l => l > 0), + searchTopics.length + )} + > + {siteSpecific( +
+ +
, + groupBaseTagOptions.map((tag, index) => ( + listStateDispatch({type: "toggle", id: `topics ${sublistDelimiter} ${tag.label}`, focus: true})} + > + {tag.options.map((topic, index) => ( +
+ setSearchTopics( + s => s.includes(topic.value) + ? s.filter(v => v !== topic.value) + : [...s, topic.value] + )} + label={{topic.label}} + className="ps-3" + /> +
+ ))} +
+ ))) + } +
+ + listStateDispatch({type: "toggle", id: "difficulty", focus: below["md"](deviceSize)})} + numberSelected={searchDifficulties.length} + > + + {SIMPLE_DIFFICULTY_ITEM_OPTIONS.map((difficulty, index) => ( +
+ setSearchDifficulties( + s => s.includes(difficulty.value) + ? s.filter(v => v !== difficulty.value) + : [...s, difficulty.value] + )} + label={
+ {difficulty.label} + +
} + /> +
+ ))} +
+ {isPhy && listStateDispatch({type: "toggle", id: "books", focus: below["md"](deviceSize)})} + numberSelected={excludeBooks ? 1 : searchBooks.length} + > + <> +
+ setExcludeBooks(p => !p)} + label={Exclude skills book questions} + /> +
+ {bookOptions.map((book, index) => ( +
+ setSearchBooks( + s => s.includes(book.value) + ? s.filter(v => v !== book.value) + : [...s, book.value] + )} + label={{book.label}} + /> +
+ ))} + +
} + listStateDispatch({type: "toggle", id: "questionStatus", focus: below["md"](deviceSize)})} + numberSelected={Object.values(searchStatuses).reduce((acc, item) => acc + item, 0)} + > +
+ setSearchStatuses(s => {return {...s, hideCompleted: !s.hideCompleted};})} + label={
+ {siteSpecific("Hide fully correct", "Hide complete")} +
} + /> +
+ {/* TODO: implement new completeness filters +
+ setQuestionStatuses(s => {return {...s, notAttempted: !s.notAttempted};})} + label={
+ Not attempted + Not attempted +
} + /> +
+
+ setQuestionStatuses(s => {return {...s, complete: !s.complete};})} + label={
+ Completed + Completed +
} + /> +
+
+ setQuestionStatuses(s => {return {...s, incorrect: !s.incorrect};})} + label={
+ Try again + Try again +
} + /> +
*/} +
+ {/* TODO: implement once necessary tags are available +
+
+
+ {isAda &&
+ setQuestionStatuses(s => {return {...s, llmMarked: !s.llmMarked};})} + label={ + {"Include "} + + {" questions"} + } + /> +
}*/} + + + +
+
; +} diff --git a/src/app/components/elements/svg/DifficultyIcons.tsx b/src/app/components/elements/svg/DifficultyIcons.tsx index 9b94a57a75..d8ff5724a2 100644 --- a/src/app/components/elements/svg/DifficultyIcons.tsx +++ b/src/app/components/elements/svg/DifficultyIcons.tsx @@ -10,13 +10,17 @@ import {Circle} from "./Circle"; const difficultyIconWidth = 25; const difficultyIconXPadding = 1.5; const yPadding = 2; -const difficultyCategories = ["P", "C"]; const difficultyCategoryLevels = siteSpecific([1, 2, 3], [1, 2]); const miniHexagon = calculateHexagonProportions(difficultyIconWidth / 2, 0); const miniSquare = {width: difficultyIconWidth, height: difficultyIconWidth}; -interface DifficultyIconShapeProps {difficultyCategory: string; difficultyCategoryLevel: number; active: boolean} -function SingleDifficultyIconShape({difficultyCategory, difficultyCategoryLevel, active}: DifficultyIconShapeProps) { +interface DifficultyIconShapeProps { + difficultyCategory: string; + difficultyCategoryLevel: number; + active: boolean; + blank?: boolean; +} +function SingleDifficultyIconShape({difficultyCategory, difficultyCategoryLevel, active, blank}: DifficultyIconShapeProps) { // FIXME the calculations here need refactoring, had to rush them to get it done return {difficultyCategory === "P" ? @@ -28,18 +32,18 @@ function SingleDifficultyIconShape({difficultyCategory, difficultyCategoryLevel, } {
- {difficultyCategory} + {blank ? "" : difficultyCategory}
}
; } -export function DifficultyIcons({difficulty} : {difficulty : Difficulty}) { +export function DifficultyIcons({difficulty, blank, classnames} : {difficulty: Difficulty, blank?: boolean, classnames?: string}) { const difficultyLabel = difficultyShortLabelMap[difficulty]; const difficultyCategory = difficultyLabel[0]; const difficultyLevel = parseInt(difficultyLabel[1]); - return
+ return
; })} diff --git a/src/app/components/elements/svg/FilterCount.tsx b/src/app/components/elements/svg/FilterCount.tsx new file mode 100644 index 0000000000..4a02e59da8 --- /dev/null +++ b/src/app/components/elements/svg/FilterCount.tsx @@ -0,0 +1,27 @@ +import React from "react"; +import {Circle} from "./Circle"; +import { isPhy, siteSpecific } from "../../../services"; +import { Hexagon } from "./Hexagon"; + +const filterIconWidth = 25; + +export const FilterCount = ({count}: {count: number}) => { + return + {`${count} filters selected`} + + {siteSpecific( + , + + )} + +
+ {count} +
+
+
+
; +}; diff --git a/src/app/components/elements/svg/HierarchyFilter.tsx b/src/app/components/elements/svg/HierarchyFilter.tsx index 1260e00cb1..065d80f45f 100644 --- a/src/app/components/elements/svg/HierarchyFilter.tsx +++ b/src/app/components/elements/svg/HierarchyFilter.tsx @@ -31,7 +31,8 @@ interface HierarchySummaryProps { } interface HierarchyFilterProps extends HierarchySummaryProps { - setTierSelection: (tierIndex: number) => React.Dispatch[]>> + questionFinderFilter?: boolean; + setTierSelection: (tierIndex: number) => React.Dispatch[]>>; } function naturalLanguageList(list: string[]) { @@ -42,8 +43,8 @@ function naturalLanguageList(list: string[]) { return `${lowerCaseList.slice(0, lastIndex).join(", ")} and ${lowerCaseList[lastIndex]}`; } -function hexRowTranslation(deviceSize: DeviceSize, hexagon: HexagonProportions, i: number) { - if (i == 0 || deviceSize != "xs") { +function hexRowTranslation(deviceSize: DeviceSize, hexagon: HexagonProportions, i: number, questionFinderFilter: boolean) { + if (i == 0 || (deviceSize != "xs" && !questionFinderFilter)) { return `translate(0,${i * (6 * hexagon.quarterHeight + 2 * hexagon.padding)})`; } else { const x = (i * 2 - 1) * (hexagon.halfWidth + hexagon.padding); @@ -52,52 +53,58 @@ function hexRowTranslation(deviceSize: DeviceSize, hexagon: HexagonProportions, } } -function connectionRowTranslation(deviceSize: DeviceSize, hexagon: HexagonProportions, i: number) { - if (deviceSize != "xs") { +function connectionRowTranslation(deviceSize: DeviceSize, hexagon: HexagonProportions, i: number, questionFinderFilter: boolean) { + if (deviceSize != "xs" && !questionFinderFilter) { return `translate(${hexagon.halfWidth + hexagon.padding},${3 * hexagon.quarterHeight + hexagon.padding + i * (6 * hexagon.quarterHeight + 2 * hexagon.padding)})`; } else { return `translate(0,0)`; // positioning is managed absolutely not through transformation } } -function hexagonTranslation(deviceSize: DeviceSize, hexagon: HexagonProportions, i: number, j: number) { - if (i == 0 || deviceSize != "xs") { +function hexagonTranslation(deviceSize: DeviceSize, hexagon: HexagonProportions, i: number, j: number, questionFinderFilter: boolean) { + if (i == 0 || (deviceSize != "xs" && !questionFinderFilter)) { return `translate(${j * 2 * (hexagon.halfWidth + hexagon.padding)},0)`; } else { return `translate(0,${j * (4 * hexagon.quarterHeight + hexagon.padding)})`; } } -export function HierarchyFilterHexagonal({tiers, choices, selections, setTierSelection}: HierarchyFilterProps) { +export function HierarchyFilterHexagonal({tiers, choices, selections, questionFinderFilter, setTierSelection}: HierarchyFilterProps) { const deviceSize = useDeviceSize(); const leadingHexagon = calculateHexagonProportions(36, deviceSize === "xs" ? 2 : 8); - const hexagon = calculateHexagonProportions(36, deviceSize === "xs" ? 16 : 8); + const hexagon = calculateHexagonProportions(36, deviceSize === "xs" || !!questionFinderFilter ? 16 : 8); const focusPadding = 3; const maxOptions = choices.slice(1).map(c => c.length).reduce((a, b) => Math.max(a, b), 0); - const height = deviceSize != "xs" ? + const height = (deviceSize != "xs" && !questionFinderFilter) ? 2 * focusPadding + 4 * hexagon.quarterHeight + (tiers.length - 1) * (6 * hexagon.quarterHeight + 2 * hexagon.padding) : 2 * focusPadding + 4 * hexagon.quarterHeight + maxOptions * (4 * hexagon.quarterHeight + hexagon.padding) + (maxOptions ? hexagon.padding : 0); + const width = (8 * leadingHexagon.halfWidth) + (6 * leadingHexagon.padding) + (2 * focusPadding); - return + return Topic filter selector {/* Connections */} {tiers.slice(1).map((tier, i) => { const subject = selections?.[0]?.[0] ? selections[0][0].value : ""; - return + return c.value).indexOf(selections[i][0]?.value)} optionIndices={[...choices[i+1].keys()]} // range from 0 to choices[i+1].length targetIndices={selections[i+1]?.map(s => choices[i+1].map(c => c.value).indexOf(s.value)) || [-1]} leadingHexagonProportions={leadingHexagon} hexagonProportions={hexagon} connectionProperties={connectionProperties} - rowIndex={i} mobile={deviceSize === "xs"} className={`connection ${subject}`} + rowIndex={i} mobile={deviceSize === "xs" || !!questionFinderFilter} className={`connection ${subject}`} /> ; })} {/* Hexagons */} - {tiers.map((tier, i) => + {tiers.map((tier, i) => {choices[i].map((choice, j) => { const subject = i == 0 ? choice.value : selections[0][0].value; const isSelected = !!selections[i]?.map(s => s.value).includes(choice.value); @@ -111,7 +118,7 @@ export function HierarchyFilterHexagonal({tiers, choices, selections, setTierSel ); } - return + return
@@ -144,7 +151,7 @@ export function HierarchyFilterSummary({tiers, choices, selections}: HierarchySu const hexagon = calculateHexagonProportions(10, 2); const hexKeyPoints = addHexagonKeyPoints(hexagon); const connection = {length: 60}; - const height = `${hexagon.quarterHeight * 4 + hexagon.padding * 2 + 32}px` + const height = `${hexagon.quarterHeight * 4 + hexagon.padding * 2 + 32}px`; if (! selections[0]?.length) { return @@ -176,7 +183,7 @@ export function HierarchyFilterSummary({tiers, choices, selections}: HierarchySu className={`connection`} />} - + ; })} @@ -190,7 +197,7 @@ export function HierarchyFilterSummary({tiers, choices, selections}: HierarchySu
-
+
; })}
; diff --git a/src/app/components/handlers/ShowLoading.tsx b/src/app/components/handlers/ShowLoading.tsx index 9222b6c8aa..3a7d617b37 100644 --- a/src/app/components/handlers/ShowLoading.tsx +++ b/src/app/components/handlers/ShowLoading.tsx @@ -17,7 +17,7 @@ const defaultPlaceholder =
; -export const ShowLoading = ({until, children, thenRender, placeholder=defaultPlaceholder, ifNotFound=}: ShowLoadingProps) => { +export const ShowLoading = >({until, children, thenRender, placeholder=defaultPlaceholder, ifNotFound=}: ShowLoadingProps) => { const [duringLoad, setDuringLoad] = useState(false); useEffect( () => { let timeout: number; diff --git a/src/app/components/pages/QuestionFinder.tsx b/src/app/components/pages/QuestionFinder.tsx index d396d38fc9..2ff99e0d9a 100644 --- a/src/app/components/pages/QuestionFinder.tsx +++ b/src/app/components/pages/QuestionFinder.tsx @@ -3,26 +3,18 @@ import { AppState, clearQuestionSearch, searchQuestions, - updateCurrentUser, useAppDispatch, useAppSelector } from "../../state"; -import * as RS from "reactstrap"; import debounce from "lodash/debounce"; -import {MultiValue} from "react-select"; import { tags, - DIFFICULTY_ICON_ITEM_OPTIONS, EXAM_BOARD_NULL_OPTIONS, getFilteredExamBoardOptions, - getFilteredStageOptions, - groupTagSelectionsByParent, isAda, isPhy, - isStaff, Item, logEvent, - selectOnChange, siteSpecific, STAGE, useUserViewingContext, @@ -30,33 +22,33 @@ import { useQueryParams, arrayFromPossibleCsv, toSimpleCSV, - itemiseByValue, TAG_ID, itemiseTag, - isLoggedIn, - SEARCH_RESULTS_PER_PAGE + SEARCH_RESULTS_PER_PAGE, } from "../../services"; import {ContentSummaryDTO, Difficulty, ExamBoard} from "../../../IsaacApiTypes"; -import {GroupBase} from "react-select/dist/declarations/src/types"; import {IsaacSpinner} from "../handlers/IsaacSpinner"; -import {StyledSelect} from "../elements/inputs/StyledSelect"; import { RouteComponentProps, useHistory, withRouter } from "react-router"; import { LinkToContentSummaryList } from "../elements/list-groups/ContentSummaryListGroupItem"; import { ShowLoading } from "../handlers/ShowLoading"; import { TitleAndBreadcrumb } from "../elements/TitleAndBreadcrumb"; import { MetaDescription } from "../elements/MetaDescription"; import { CanonicalHrefElement } from "../navigation/CanonicalHrefElement"; -import { HierarchyFilterHexagonal, Tier, TierID } from "../elements/svg/HierarchyFilter"; -import { StyledCheckbox } from "../elements/inputs/StyledCheckbox"; import classNames from "classnames"; import queryString from "query-string"; import { PageFragment } from "../elements/PageFragment"; import {RenderNothing} from "../elements/RenderNothing"; - -const selectStyle = { - className: "basic-multi-select", classNamePrefix: "select", - menuPortalTarget: document.body, styles: {menuPortal: (base: object) => ({...base, zIndex: 9999})} -}; +import { Button, Card, CardBody, CardHeader, Col, Container, Input, InputGroup, Label, Row } from "reactstrap"; +import { QuestionFinderFilterPanel } from "../elements/panels/QuestionFinderFilterPanel"; +import { Tier, TierID } from "../elements/svg/HierarchyFilter"; + +export interface QuestionStatus { + notAttempted: boolean; + complete: boolean; + incorrect: boolean; + llmMarked: boolean; + hideCompleted: boolean; // TODO: remove when implementing desired filters +} function processTagHierarchy(subjects: string[], fields: string[], topics: string[]): Item[][] { const tagHierarchy = tags.getTagHierarchy(); @@ -83,48 +75,55 @@ export const QuestionFinder = withRouter(({location}: RouteComponentProps) => { const history = useHistory(); const eventLog = useRef([]).current; // persist state but do not rerender on mutation - const [searchTopics, setSearchTopics] = useState( - arrayFromPossibleCsv(params.topics) - ); - const [searchQuery, setSearchQuery] = useState( - params.query ? (params.query instanceof Array ? params.query[0] : params.query) : "" - ); - const [searchStages, setSearchStages] = useState( - arrayFromPossibleCsv(params.stages) as STAGE[] - ); - const [searchDifficulties, setSearchDifficulties] = useState( - arrayFromPossibleCsv(params.difficulties) as Difficulty[] - ); - const [searchExamBoards, setSearchExamBoards] = useState( - arrayFromPossibleCsv(params.examBoards) as ExamBoard[] + const [searchTopics, setSearchTopics] = useState(arrayFromPossibleCsv(params.topics)); + const [searchQuery, setSearchQuery] = useState(params.query ? (params.query instanceof Array ? params.query[0] : params.query) : ""); + const [searchStages, setSearchStages] = useState(arrayFromPossibleCsv(params.stages) as STAGE[]); + const [searchDifficulties, setSearchDifficulties] = useState(arrayFromPossibleCsv(params.difficulties) as Difficulty[]); + const [searchExamBoards, setSearchExamBoards] = useState(arrayFromPossibleCsv(params.examBoards) as ExamBoard[]); + const [searchStatuses, setSearchStatuses] = useState( + { + notAttempted: false, + complete: false, + incorrect: false, + llmMarked: false, + hideCompleted: !!params.hideCompleted + } ); + const [searchBooks, setSearchBooks] = useState(arrayFromPossibleCsv(params.book)); + const [excludeBooks, setExcludeBooks] = useState(!!params.excludeBooks); - useEffect(function populateExamBoardFromUserContext() { - if (!EXAM_BOARD_NULL_OPTIONS.includes(userContext.examBoard)) setSearchExamBoards([userContext.examBoard]); - }, [userContext.examBoard]); + const [populatedUserContext, setPopulatedUserContext] = useState(false); - useEffect(function populateStageFromUserContext() { - if (!STAGE_NULL_OPTIONS.includes(userContext.stage)) setSearchStages([userContext.stage]); - }, [userContext.stage]); + useEffect(function populateFromUserContext() { + if (!STAGE_NULL_OPTIONS.includes(userContext.stage)) { + setSearchStages(arr => arr.length > 0 ? arr : [userContext.stage]); + } + if (!EXAM_BOARD_NULL_OPTIONS.includes(userContext.examBoard)) { + setSearchExamBoards(arr => arr.length > 0 ? arr : [userContext.examBoard]); + } + setPopulatedUserContext(!!userContext.stage && !!userContext.examBoard); + }, [userContext.stage, userContext.examBoard]); + + // this acts as an "on complete load", needed as we can only correctly update the URL once we have the user context *and* React has processed the above setStates + useEffect(() => { + searchAndUpdateURL(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [populatedUserContext]); - const userPreferences = useAppSelector((state: AppState) => state?.userPreferences); - const [searchBook, setSearchBook] = useState(arrayFromPossibleCsv(params.book)); - const [searchFastTrack, setSearchFastTrack] = useState(!!params.fasttrack); const [disableLoadMore, setDisableLoadMore] = useState(false); - const subjects = arrayFromPossibleCsv(params.subjects); - const fields = arrayFromPossibleCsv(params.fields); - const topics = arrayFromPossibleCsv(params.topics); const [selections, setSelections] = useState[][]>( - processTagHierarchy(subjects, fields, topics) + processTagHierarchy( + arrayFromPossibleCsv(params.subjects), + arrayFromPossibleCsv(params.fields), + arrayFromPossibleCsv(params.topics) + ) ); - const [hideCompleted, setHideCompleted] = useState(!!params.hideCompleted); - const choices = [tags.allSubjectTags.map(itemiseTag)]; - let index; - for (index = 0; index < selections.length && index < 2; index++) { - const selection = selections[index]; + let tierIndex; + for (tierIndex = 0; tierIndex < selections.length && tierIndex < 2; tierIndex++) { + const selection = selections[tierIndex]; if (selection.length !== 1) break; choices.push(tags.getChildren(selection[0].value).map(itemiseTag)); } @@ -133,27 +132,24 @@ export const QuestionFinder = withRouter(({location}: RouteComponentProps) => { {id: "subjects" as TierID, name: "Subject"}, {id: "fields" as TierID, name: "Field"}, {id: "topics" as TierID, name: "Topic"} - ].map(tier => ({...tier, for: "for_" + tier.id})).slice(0, index + 1); - - const tagOptions: { options: Item[]; label: string }[] = isPhy ? tags.allTags.map(groupTagSelectionsByParent) : tags.allSubcategoryTags.map(groupTagSelectionsByParent); - const groupBaseTagOptions: GroupBase>[] = tagOptions; - const bookOptions: Item[] = [ - {value: "phys_book_step_up", label: "Step Up to GCSE Physics"}, - {value: "phys_book_gcse", label: "GCSE Physics"}, - {value: "physics_skills_19", label: "A Level Physics (3rd Edition)"}, - {value: "physics_linking_concepts", label: "Linking Concepts in Pre-Uni Physics"}, - {value: "maths_book_gcse", label: "GCSE Maths"}, - {value: "maths_book", label: "Pre-Uni Maths"}, - {value: "chemistry_16", label: "A-Level Physical Chemistry"} - ]; + ].map(tier => ({...tier, for: "for_" + tier.id})).slice(0, tierIndex + 1); + + const setTierSelection = (tierIndex: number) => { + return ((values: Item[]) => { + const newSelections = selections.slice(0, tierIndex); + newSelections.push(values); + setSelections(newSelections); + }) as React.Dispatch[]>>; + }; const {results: questions, totalResults: totalQuestions, nextSearchOffset} = useAppSelector((state: AppState) => state && state.questionSearchResult) || {}; - const user = useAppSelector((state: AppState) => state && state.user); + const nothingToSearchFor = + [searchQuery, searchTopics, searchBooks, searchStages, searchDifficulties, searchExamBoards].every(v => v.length === 0) && + selections.every(v => v.length === 0); const searchDebounce = useCallback( - debounce((searchString: string, topics: string[], examBoards: string[], book: string[], stages: string[], difficulties: string[], hierarchySelections: Item[][], tiers: Tier[], fasttrack: boolean, hideCompleted: boolean, startIndex: number) => { - if ([searchString, topics, book, stages, difficulties, examBoards].every(v => v.length === 0) && hierarchySelections.every(v => v.length === 0) && !fasttrack) { - // Nothing to search for + debounce((searchString: string, topics: string[], examBoards: string[], book: string[], stages: string[], difficulties: string[], hierarchySelections: Item[][], tiers: Tier[], excludeBooks: boolean, hideCompleted: boolean, startIndex: number) => { + if (nothingToSearchFor) { dispatch(clearQuestionSearch); return; } @@ -181,46 +177,32 @@ export const QuestionFinder = withRouter(({location}: RouteComponentProps) => { fields: filterParams.fields?.join(",") || undefined, subjects: filterParams.subjects?.join(",") || undefined, topics: filterParams.topics?.join(",") || undefined, - books: book.join(",") || undefined, + books: (!excludeBooks && book.join(",")) || undefined, stages: stages.join(",") || undefined, difficulties: difficulties.join(",") || undefined, examBoards: examBoardString, - fasttrack, + questionCategories: isPhy + ? (excludeBooks ? "problem_solving" : "problem_solving,book") + : undefined, + fasttrack: false, hideCompleted, startIndex, limit: SEARCH_RESULTS_PER_PAGE + 1 // request one more than we need, as to know if there are more results })); - logEvent(eventLog,"SEARCH_QUESTIONS", {searchString, topics, examBoards, book, stages, difficulties, fasttrack, startIndex}); + logEvent(eventLog,"SEARCH_QUESTIONS", {searchString, topics, examBoards, book, stages, difficulties, startIndex}); }, 250), - [] + [nothingToSearchFor] ); - const setTierSelection = (tierIndex: number) => { - return ((values: Item[]) => { - const newSelections = selections.slice(0, tierIndex); - newSelections.push(values); - setSelections(newSelections); - }) as React.Dispatch[]>>; - }; - - useEffect(() => { - // If a certain stage excludes a selected examboard remove it from query params - if (isAda) { - setSearchExamBoards( - getFilteredExamBoardOptions({byStages: searchStages}) - .filter(o => searchExamBoards.includes(o.value)) - .map(o => o.value) - ); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [searchStages]); - - useEffect(() => { + const searchAndUpdateURL = useCallback(() => { setPageCount(1); setDisableLoadMore(false); setDisplayQuestions(undefined); - searchDebounce(searchQuery, searchTopics, searchExamBoards, searchBook, searchStages, searchDifficulties, selections, tiers, searchFastTrack, hideCompleted, 0); + searchDebounce( + searchQuery, searchTopics, searchExamBoards, searchBooks, searchStages, + searchDifficulties, selections, tiers, excludeBooks, searchStatuses.hideCompleted, 0 + ); const params: {[key: string]: string} = {}; if (searchStages.length) params.stages = toSimpleCSV(searchStages); @@ -228,9 +210,11 @@ export const QuestionFinder = withRouter(({location}: RouteComponentProps) => { if (searchQuery.length) params.query = encodeURIComponent(searchQuery); if (isAda && searchTopics.length) params.topics = toSimpleCSV(searchTopics); if (isAda && searchExamBoards.length) params.examBoards = toSimpleCSV(searchExamBoards); - if (isPhy && searchBook.length) params.book = toSimpleCSV(searchBook); - if (isPhy && searchFastTrack) params.fasttrack = "set"; - if (hideCompleted) params.hideCompleted = "set"; + if (isPhy && !excludeBooks && searchBooks.length) { + params.book = toSimpleCSV(searchBooks); + } + if (isPhy && excludeBooks) params.excludeBooks = "set"; + if (searchStatuses.hideCompleted) params.hideCompleted = "set"; if (isPhy) { tiers.forEach((tier, i) => { @@ -242,8 +226,28 @@ export const QuestionFinder = withRouter(({location}: RouteComponentProps) => { } history.replace({search: queryString.stringify(params, {encode: false}), state: location.state}); + }, [excludeBooks, history, location.state, searchStatuses.hideCompleted, searchBooks, searchDebounce, searchDifficulties, searchExamBoards, searchQuery, searchStages, searchTopics, selections, tiers]); + + const [filtersApplied, setFiltersApplied] = useState(false); + const applyFilters = () => { + setFiltersApplied(true); + searchAndUpdateURL(); + }; + + // Automatically search for content whenever the searchQuery changes, without changing whether filters have been applied or not // eslint-disable-next-line react-hooks/exhaustive-deps - },[searchDebounce, searchQuery, searchTopics, searchExamBoards, searchBook, searchFastTrack, searchStages, searchDifficulties, selections, hideCompleted]); + useEffect(searchAndUpdateURL, [searchQuery]); + + // If the stages filter changes, update the exam board filter selections to remove now-incompatible ones + useEffect(() => { + if (isAda) { + setSearchExamBoards(examBoards => + getFilteredExamBoardOptions({byStages: searchStages}) + .filter(o => examBoards.includes(o.value)) + .map(o => o.value) + ); + } + }, [searchStages]); const questionList = useMemo(() => { if (questions) { @@ -257,7 +261,7 @@ export const QuestionFinder = withRouter(({location}: RouteComponentProps) => { } }, [questions]); - const [displayQuestions, setDisplayQuestions] = useState(undefined); + const [displayQuestions, setDisplayQuestions] = useState([]); const [pageCount, setPageCount] = useState(1); useEffect(() => { @@ -269,23 +273,35 @@ export const QuestionFinder = withRouter(({location}: RouteComponentProps) => { // eslint-disable-next-line react-hooks/exhaustive-deps }, [questionList]); - const [revisionMode, setRevisionMode] = useState(!!userPreferences?.DISPLAY_SETTING?.HIDE_QUESTION_ATTEMPTS); - - useEffect(() => { - if (revisionMode) { - setHideCompleted(false); - } - }, [revisionMode]); - - const debouncedRevisionModeUpdate = useCallback(debounce(() => { - if (user && isLoggedIn(user)) { - const userToUpdate = {...user, password: null}; - const userPreferencesToUpdate = { - DISPLAY_SETTING: {...userPreferences?.DISPLAY_SETTING, HIDE_QUESTION_ATTEMPTS: !userPreferences?.DISPLAY_SETTING?.HIDE_QUESTION_ATTEMPTS} - }; - dispatch(updateCurrentUser(userToUpdate, userPreferencesToUpdate, undefined, null, user, false)); - }}, 250, {trailing: true} - ), []); + const filtersSelected = useMemo(() => { + setFiltersApplied(false); + return searchDifficulties.length > 0 + || searchTopics.length > 0 + || searchExamBoards.length > 0 + || searchStages.length > 0 + || searchBooks.length > 0 + || excludeBooks + || selections.some(tier => tier.length > 0) + || Object.entries(searchStatuses).some(e => e[1]); + }, [searchDifficulties, searchTopics, searchExamBoards, searchStages, searchBooks, excludeBooks, selections, searchStatuses]); + + const clearFilters = useCallback(() => { + setSearchDifficulties([]); + setSearchTopics([]); + setSearchExamBoards([]); + setSearchStages([]); + setSearchBooks([]); + setExcludeBooks(false); + setSelections([[], [], []]); + setSearchStatuses( + { + notAttempted: false, + complete: false, + incorrect: false, + llmMarked: false, + hideCompleted: false + }); + }, []); // eslint-disable-next-line react-hooks/exhaustive-deps const handleSearch = useCallback( @@ -311,163 +327,88 @@ export const QuestionFinder = withRouter(({location}: RouteComponentProps) => {
; - return - + return + - - - - - Specify your search criteria and we will find questions related to your chosen filter(s). - - - - - Stage - searchStages.includes(o.value))} - options={getFilteredStageOptions()} - onChange={selectOnChange(setSearchStages, true)} - /> - - - Difficulty - - - {isAda && - Exam Board - searchExamBoards.includes(o.value))} - options={getFilteredExamBoardOptions({byStages: searchStages})} - onChange={(s: MultiValue>) => selectOnChange(setSearchExamBoards, true)(s)} + + + + + + handleSearch(e.target.value)} /> - } - - - - Topic - {siteSpecific( - , - tag.options))} - options={groupBaseTagOptions} onChange={(x : readonly Item[], {action: _action}) => { - selectOnChange(setSearchTopics, true)(x); + + + } + + + ; }); diff --git a/src/app/services/constants.ts b/src/app/services/constants.ts index f9a0536c1d..38e1a7ff68 100644 --- a/src/app/services/constants.ts +++ b/src/app/services/constants.ts @@ -390,6 +390,14 @@ export const difficultyLabelMap: {[difficulty in Difficulty]: string} = { challenge_2: "Challenge\u00A0(C2)", challenge_3: "Challenge\u00A0(C3)", }; +export const simpleDifficultyLabelMap: {[difficulty in Difficulty]: string} = { + practice_1: "Practice\u00A01", + practice_2: "Practice\u00A02", + practice_3: "Practice\u00A03", + challenge_1: "Challenge\u00A01", + challenge_2: "Challenge\u00A02", + challenge_3: "Challenge\u00A03", +}; export const difficultyIconLabelMap: {[difficulty in Difficulty]: string} = { practice_1: `Practice (P1) ${siteSpecific("\u2B22\u2B21\u2B21", "\u25CF\u25CB")}`, practice_2: `Practice (P2) ${siteSpecific("\u2B22\u2B22\u2B21", "\u25CF\u25CF")}`, @@ -405,6 +413,9 @@ export const difficultiesOrdered: Difficulty[] = siteSpecific( export const DIFFICULTY_ITEM_OPTIONS: {value: Difficulty, label: string}[] = difficultiesOrdered.map(d => ( {value: d, label: difficultyLabelMap[d]} )); +export const SIMPLE_DIFFICULTY_ITEM_OPTIONS: {value: Difficulty, label: string}[] = difficultiesOrdered.map(d => ( + {value: d, label: simpleDifficultyLabelMap[d]} +)); export const DIFFICULTY_ICON_ITEM_OPTIONS: {value: Difficulty, label: string}[] = difficultiesOrdered.map(d => ( {value: d, label: difficultyIconLabelMap[d]} )); diff --git a/src/scss/common/_utils.scss b/src/scss/common/_utils.scss index 0b192ca899..171590fb96 100644 --- a/src/scss/common/_utils.scss +++ b/src/scss/common/_utils.scss @@ -29,6 +29,14 @@ overflow-x: auto !important; } +.h-min-content { + height: min-content !important; +} + +.h-max-content { + height: max-content !important; +} + .w-min-content { width: min-content !important; } @@ -39,4 +47,4 @@ .w-max-content { width: max-content !important; -} \ No newline at end of file +} diff --git a/src/scss/common/checkbox.scss b/src/scss/common/checkbox.scss index c6b8f4aad5..921b64dd8b 100644 --- a/src/scss/common/checkbox.scss +++ b/src/scss/common/checkbox.scss @@ -1,35 +1,46 @@ .styled-checkbox-wrapper { display: flex; justify-content: center; - + align-items: center; + div { width: 1.6em; min-width: 1.6em; height: 1.6em; position: relative; - + input[type="checkbox"] { position: relative; appearance: none; width: 100%; height: 100%; - border: 0.15em solid #000; + border: 0.1em solid #000; border-radius: 0.25em; margin: 0; outline: none; cursor: pointer; transition: all 250ms cubic-bezier(0.1, 0.75, 0.5, 1); - + &.checked { background: #000; border-color: #000; - + &:hover, &:disabled { background: #333; border-color: #333; } + + &[color="primary"] { + background: $primary; + border-color: $primary; + + &:hover, &:disabled { + background: darken($primary, 10%); + border-color: darken($primary, 10%); + } + } } - + &:not(.checked):hover, &:not(.checked):disabled { background: #f8f8f8; border-color: #666; @@ -39,7 +50,7 @@ box-shadow: 0 0 0 0.1em #000; } } - + .tick { position: absolute; display: inline-block; @@ -51,7 +62,7 @@ -ms-transform: rotate(45deg); pointer-events: none; z-index: 1; - + &::before { content: ""; position: absolute; @@ -61,7 +72,7 @@ left: 11px; top: 2px; } - + &::after{ content: ""; position: absolute; @@ -73,10 +84,17 @@ } } } - + > label { width: fit-content; margin: 0; line-height: normal; + + &.hover-override + div input[type="checkbox"]:not(:checked) { + &:hover { + background: #f8f8f8; + border-color: #666; + } + } } } diff --git a/src/scss/common/elements.scss b/src/scss/common/elements.scss index ecbf135ba5..29003f960f 100644 --- a/src/scss/common/elements.scss +++ b/src/scss/common/elements.scss @@ -294,3 +294,22 @@ iframe.email-html { // } // } //} + +.collapsible-head { + margin-left: 0; + margin-right: 0; + border-top-style: solid; + border-bottom-style: solid; + border-width: 1px; + border-color: $gray-107; + + img { + transition: transform 0.1s ease; + } +} + +.collapsible-body { + transition: max-height 0.3s ease-in-out, height 0.3s ease-in-out; + // height must be animated alongside max-height to prevent jumping if the inner content changes height during the animation + max-height: 0; +} diff --git a/src/scss/common/filter.scss b/src/scss/common/filter.scss index 08b4941dac..da31eb5dee 100644 --- a/src/scss/common/filter.scss +++ b/src/scss/common/filter.scss @@ -1,4 +1,8 @@ -.hexagon-tier-title, .hexagon-level-title, .hexagon-tier-summary, .difficulty-title { +.hexagon-tier-title, +.hexagon-level-title, +.hexagon-tier-summary, +.difficulty-title, +.filter-count-title { pointer-events: none; height: 100%; display: flex; @@ -35,7 +39,7 @@ font-weight: 100; } -.difficulty-title { +.difficulty-title, .filter-count-title { font-weight: 400; font-size: 0.92rem; &.active {color: white;} @@ -44,4 +48,3 @@ font-size: 0.8rem; } } - diff --git a/src/scss/common/finder.scss b/src/scss/common/finder.scss index e0d22e51dc..e84727df60 100644 --- a/src/scss/common/finder.scss +++ b/src/scss/common/finder.scss @@ -1,8 +1,43 @@ #finder-page { .finder-header { - display: flex; - flex-wrap: wrap; - align-items: center; + display: flex; + flex-wrap: wrap; + align-items: center; + } + + .finder-search { + $search-button-offset: 50px; + .question-search-button { + background: white url(/assets/cs/icons/search-jet.svg) no-repeat center; + background-size: 20px 20px; + border: 1px solid black; + border-left: none; + border-radius: 0 var(--bs-border-radius) var(--bs-border-radius) 0; + padding: 0.45rem 0; + width: $search-button-offset; + min-width: 0; + &:active { + background-color: lightgray !important; + } + } + + &::before { + content: ""; + display: block; + position: relative; + float: right; + width: 1px; + height: 34px; + top: 48px; + right: $search-button-offset; + background: #c2c2c2; + z-index: 4; + } + + input#question-search-title { + padding-right: calc($search-button-offset + 1.2em) !important; // offset plus the default padding inside an input + height: 50px; + } } .search-item-icon { @@ -24,5 +59,17 @@ margin-right: 0.5rem; } } + + .filter-btn { + position: -webkit-sticky; + position: sticky; + bottom: 0; + } + + .filter-separator { + overflow: hidden; + border-top: solid #c2c2c2 1px; + border-bottom: none; + } } diff --git a/src/scss/common/icons.scss b/src/scss/common/icons.scss index 43416e37a6..2537b791e3 100644 --- a/src/scss/common/icons.scss +++ b/src/scss/common/icons.scss @@ -22,14 +22,27 @@ polygon.fill-secondary { fill: $secondary; } -.icon-dropdown { +.dropzone-dropdown { position: absolute; align-self: center; right: 5px; + @extend .icon-dropdown-180; +} + +@mixin icon-dropdown($deg) { + transform: rotate($deg); + -webkit-transform: rotate($deg); + -ms-transform: rotate($deg); +} + +.icon-dropdown-180 { + &.active { + @include icon-dropdown(180deg); + } +} +.icon-dropdown-90 { &.active { - transform: rotate(180deg); - -webkit-transform: rotate(180deg); - -ms-transform: rotate(180deg); + @include icon-dropdown(90deg); } } diff --git a/src/scss/cs/boards.scss b/src/scss/cs/boards.scss index c06d0f603f..32e2e5676a 100644 --- a/src/scss/cs/boards.scss +++ b/src/scss/cs/boards.scss @@ -43,19 +43,17 @@ } .question-progress-icon { - width: 95px !important; - min-width: 95px !important; + height: 88px; display: block; padding: 14px 0 !important; position: relative; + align-content: center; .inner-progress-icon { @extend .text-center; @extend .px-2; position: relative; - top: calc(50% + 5px); - transform: translateY(-50%); img { - width: 2rem; + max-width: 2rem; max-height: 2rem; } .icon-title { diff --git a/src/scss/cs/filter.scss b/src/scss/cs/filter.scss index 308013772e..c12e617029 100644 --- a/src/scss/cs/filter.scss +++ b/src/scss/cs/filter.scss @@ -35,7 +35,7 @@ } } -$shapes: hex, square, octagon, diamond; +$shapes: hex, square, octagon, diamond, circle; svg { @each $shape in $shapes { @@ -71,6 +71,11 @@ svg { stroke-width: 1px; } + &.filter-count { + fill: $pink-300; + opacity: .05; + } + &:focus { outline: none; stroke: black !important; diff --git a/src/scss/cs/finder.scss b/src/scss/cs/finder.scss new file mode 100644 index 0000000000..2134d4936b --- /dev/null +++ b/src/scss/cs/finder.scss @@ -0,0 +1,14 @@ +@import "../common/finder"; + +@media (min-width: 992px) { + .finder-panel::before { + position: absolute; + content: ""; + width: 500px; + height: 500px; + top: 430px; + left: 61%; + background-size: cover; + background-image: url("/assets/cs/decor/dots-circle-bg.svg"); + } +} diff --git a/src/scss/cs/isaac.scss b/src/scss/cs/isaac.scss index 6e04c0fb6f..84d3152e31 100644 --- a/src/scss/cs/isaac.scss +++ b/src/scss/cs/isaac.scss @@ -312,6 +312,8 @@ $spacers: map-merge($spacers, ( // Special Bootstrap variable that is used to ge 6: ($spacer * 6.25) // 100px-ish )); +$enable-negative-margins: true; + // all Bootstrap overrides must appear before this @import "~bootstrap/scss/bootstrap"; @import "~katex/dist/katex.min.css"; @@ -383,7 +385,7 @@ $spacers: map-merge($spacers, ( // Special Bootstrap variable that is used to ge @import "../common/glossary"; @import "quiz"; @import "../common/callout"; -@import "../common/finder"; +@import "finder"; @import "topics"; @import "expansion-layout"; diff --git a/src/scss/phy/filter.scss b/src/scss/phy/filter.scss index 6da8ce338f..dd77309a57 100644 --- a/src/scss/phy/filter.scss +++ b/src/scss/phy/filter.scss @@ -44,7 +44,7 @@ } } -$shapes: hex, square, octagon, diamond; +$shapes: hex, square, octagon, diamond, circle; svg { @each $shape in $shapes { @@ -107,6 +107,12 @@ svg { stroke: $phy_extra_force_yellow; } + &.filter-count { + fill: $phy_green; + opacity: .1; + stroke: none; + } + &:focus { outline: none; stroke: black !important; diff --git a/src/scss/phy/finder.scss b/src/scss/phy/finder.scss new file mode 100644 index 0000000000..938f52966a --- /dev/null +++ b/src/scss/phy/finder.scss @@ -0,0 +1,5 @@ +@import "../common/finder"; + +.question-finder-container { + max-width: 1140px; +} diff --git a/src/scss/phy/isaac.scss b/src/scss/phy/isaac.scss index a2f7478b27..81a7d651aa 100644 --- a/src/scss/phy/isaac.scss +++ b/src/scss/phy/isaac.scss @@ -81,6 +81,8 @@ $question-font-weight: 600; // forms $border-color: black; +$enable-negative-margins: true; + // ISAAC // Node module imports @import "~bootstrap/scss/functions"; @@ -210,7 +212,7 @@ $theme-colors-border-subtle: map-merge($theme-colors-border-subtle, $custom-colo @import "my-account"; @import "../common/topic"; @import "../common/search"; -@import "../common/finder"; +@import "finder"; @import "../common/my-progress"; @import "gameboard"; @import "groups";