Skip to content

Commit edb97fc

Browse files
authored
Merge pull request #1463 from isaacphysics/redesign/qf-concepts-improvements
Redesign: concept finder improvements
2 parents 64549be + ee8b99c commit edb97fc

File tree

4 files changed

+97
-88
lines changed

4 files changed

+97
-88
lines changed

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

Lines changed: 42 additions & 69 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import React, { ChangeEvent, Dispatch, RefObject, SetStateAction, useEffect, useRef, useState } from "react";
1+
import React, { ChangeEvent, Dispatch, RefObject, SetStateAction, useEffect, useMemo, 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";
@@ -9,7 +9,7 @@ import { above, ACCOUNT_TAB, ACCOUNT_TABS, AUDIENCE_DISPLAY_FIELDS, below, BOARD
99
Item, stageLabelMap, extractTeacherName, determineGameboardSubjects, PATHS, getQuestionPlaceholder, getFilteredStageOptions,
1010
isPhy,
1111
ISAAC_BOOKS,
12-
BookHiddenState} from "../../../services";
12+
BookHiddenState, TAG_LEVEL} from "../../../services";
1313
import { StageAndDifficultySummaryIcons } from "../StageAndDifficultySummaryIcons";
1414
import { mainContentIdSlice, selectors, useAppDispatch, useAppSelector, useGetQuizAssignmentsAssignedToMeQuery } from "../../../state";
1515
import { Link, useHistory, useLocation } from "react-router-dom";
@@ -337,9 +337,13 @@ const FilterCheckbox = (props : FilterCheckboxProps) => {
337337
}, [conceptFilters, tag]);
338338

339339
const handleCheckboxChange = (checked: boolean) => {
340+
// Reselect parent if all children are deselected
341+
const siblingTags = tag.type === TAG_LEVEL.field && tag.parent ? tags.getDirectDescendents(tag.parent).filter(t => t !== tag) : [];
342+
const reselectParent = tag.parent && siblingTags.every(t => !conceptFilters.includes(t));
343+
340344
const newConceptFilters = checked
341345
? [...conceptFilters.filter(c => !incompatibleTags?.includes(c)), ...(!partiallySelected ? [tag] : [])]
342-
: conceptFilters.filter(c => ![tag, ...(dependentTags ?? [])].includes(c));
346+
: [...conceptFilters.filter(c => ![tag, ...(dependentTags ?? [])].includes(c)), ...(reselectParent ? [tags.getById(tag.parent!)] : [])];
343347
setConceptFilters(newConceptFilters.length > 0 ? newConceptFilters : (baseTag ? [baseTag] : []));
344348
};
345349

@@ -428,27 +432,32 @@ export const SubjectSpecificConceptListSidebar = (props: ConceptListSidebarProps
428432
}
429433
</div>
430434
</search>
431-
432-
<div className="section-divider"/>
433-
434-
<div className="sidebar-help">
435-
<p>The concepts shown on this page have been filtered to only show those that are relevant to {getHumanContext(pageContext)}.</p>
436-
<p>If you want to explore broader concepts across multiple subjects or learning stages, you can use the main concept browser:</p>
437-
<AffixButton size="md" color="keyline" tag={Link} to="/concepts" affix={{
438-
affix: "icon-right",
439-
position: "suffix",
440-
type: "icon"
441-
}}>
442-
Browse concepts
443-
</AffixButton>
444-
</div>
445435
</ContentSidebar>;
446436
};
447437

448-
export const GenericConceptsSidebar = (props: ConceptListSidebarProps) => {
449-
const { searchText, setSearchText, conceptFilters, setConceptFilters, applicableTags, tagCounts, ...rest } = props;
438+
interface GenericConceptsSidebarProps extends ConceptListSidebarProps {
439+
searchStages: Stage[];
440+
setSearchStages: React.Dispatch<React.SetStateAction<Stage[]>>;
441+
stageCounts: Record<string, number>;
442+
}
450443

451-
const pageContext = useAppSelector(selectors.pageContext.context);
444+
export const GenericConceptsSidebar = (props: GenericConceptsSidebarProps) => {
445+
const { searchText, setSearchText, conceptFilters, setConceptFilters, tagCounts, searchStages, setSearchStages, stageCounts, ...rest } = props;
446+
447+
const updateSearchStages = (stage: Stage) => {
448+
if (searchStages.includes(stage)) {
449+
setSearchStages(searchStages.filter(s => s !== stage));
450+
} else {
451+
setSearchStages([...(searchStages ?? []), stage]);
452+
}
453+
};
454+
455+
// If exactly one subject is selected, infer a colour for the stage checkboxes
456+
const singleSubjectColour = useMemo(() => {
457+
return conceptFilters.length === 1 && conceptFilters[0].type === TAG_LEVEL.subject ? conceptFilters[0].id
458+
: conceptFilters.length && conceptFilters.every(tag => tag.parent === conceptFilters[0].parent) ? conceptFilters[0].parent
459+
: undefined;
460+
}, [conceptFilters]);
452461

453462
return <ContentSidebar {...rest}>
454463
<div className="section-divider"/>
@@ -479,7 +488,7 @@ export const GenericConceptsSidebar = (props: ConceptListSidebarProps) => {
479488
/>
480489
{isSelected && <div className="ms-3 ps-2">
481490
{descendentTags
482-
.filter(tag => !isDefined(tagCounts) || tagCounts[tag.id] > 0)
491+
.filter(tag => !isDefined(tagCounts) || tagCounts[tag.id] > 0 || conceptFilters.includes(tag))
483492
// .sort((a, b) => tagCounts ? tagCounts[b.id] - tagCounts[a.id] : 0)
484493
.map((tag, j) => <FilterCheckbox key={j}
485494
checkboxStyle="button" color="theme" bsSize="sm" data-bs-theme={subject} tag={tag} conceptFilters={conceptFilters}
@@ -489,26 +498,20 @@ export const GenericConceptsSidebar = (props: ConceptListSidebarProps) => {
489498
</div>}
490499
</div>;
491500
})}
501+
<div className="section-divider"/>
502+
<h5>Filter by stage</h5>
503+
<ul className="ps-2">
504+
{getFilteredStageOptions().filter(s => stageCounts[s.value] > 0 || searchStages.includes(s.value)).map((stage) =>
505+
<li key={stage.value}>
506+
<StyledCheckbox checked={searchStages.includes(stage.value)}
507+
label={<>{stage.label} <span className="text-muted">({stageCounts[stage.value]})</span></>}
508+
data-bs-theme={singleSubjectColour}
509+
color="theme" onChange={() => {updateSearchStages(stage.value);}}/>
510+
</li>)}
511+
</ul>
492512
</div>
493513
</search>
494514

495-
<div className="section-divider"/>
496-
497-
{pageContext?.subject && <>
498-
<div className="section-divider"/>
499-
500-
<div className="sidebar-help">
501-
<p>The concepts shown on this page have been filtered to only show those that are relevant to {getHumanContext(pageContext)}.</p>
502-
<p>If you want to explore broader concepts across multiple subjects or learning stages, you can use the main concept browser:</p>
503-
<AffixButton size="md" color="keyline" tag={Link} to="/concepts" affix={{
504-
affix: "icon-right",
505-
position: "suffix",
506-
type: "icon"
507-
}}>
508-
Browse concepts
509-
</AffixButton>
510-
</div>
511-
</>}
512515
</ContentSidebar>;
513516
};
514517

@@ -530,7 +533,7 @@ export const QuestionFinderSidebar = (props: QuestionFinderSidebarProps) => {
530533
return <ContentSidebar {...rest}>
531534
<div className="section-divider"/>
532535
<search>
533-
<h5>Search Questions</h5>
536+
<h5>Search questions</h5>
534537
<Input
535538
className='search--filter-input my-4'
536539
type="search" value={internalSearchText || ""}
@@ -543,22 +546,6 @@ export const QuestionFinderSidebar = (props: QuestionFinderSidebarProps) => {
543546

544547
<QuestionFinderFilterPanel {...questionFinderFilterPanelProps} />
545548
</search>
546-
547-
{pageContext?.subject && pageContext?.stage && <>
548-
<div className="section-divider"/>
549-
550-
<div className="sidebar-help">
551-
<p>The questions shown here have been filtered to only show those that are relevant to {getHumanContext(pageContext)}.</p>
552-
<p>If you want to explore our full range of questions across multiple subjects or learning stages, you can use the main question finder:</p>
553-
<AffixButton size="md" color="keyline" tag={Link} to="/questions" affix={{
554-
affix: "icon-right",
555-
position: "suffix",
556-
type: "icon"
557-
}}>
558-
Browse all questions
559-
</AffixButton>
560-
</div>
561-
</>}
562549
</ContentSidebar>;
563550
};
564551

@@ -662,20 +649,6 @@ export const PracticeQuizzesSidebar = (props: PracticeQuizzesSidebarProps) => {
662649
</ul>
663650
</>}
664651

665-
{isFullyDefinedContext(pageContext) && <>
666-
<div className="section-divider"/>
667-
<div className="sidebar-help">
668-
<p>The practice tests shown here have been filtered to only show those that are relevant to {getHumanContext(pageContext)}.</p>
669-
<p>If you want to explore our full range of practice tests, you can view the main practice tests page:</p>
670-
<AffixButton size="md" color="keyline" tag={Link} to="/practice_tests" affix={{
671-
affix: "icon-right",
672-
position: "suffix",
673-
type: "icon"
674-
}}>
675-
Browse all practice tests
676-
</AffixButton>
677-
</div>
678-
</>}
679652
<div className="section-divider"/>
680653
<div className="sidebar-help">
681654
<p>You can see all of the tests that you have in progress or have completed in your My Isaac:</p>

src/app/components/pages/Concepts.tsx

Lines changed: 36 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,21 @@
11
import React, {FormEvent, MutableRefObject, useEffect, useMemo, useRef, useState} from "react";
2-
import {RouteComponentProps, withRouter} from "react-router-dom";
2+
import {Link, RouteComponentProps, withRouter} from "react-router-dom";
33
import {selectors, useAppSelector} from "../../state";
44
import {Badge, Card, CardBody, CardHeader, Container} from "reactstrap";
55
import queryString from "query-string";
6-
import {isAda, isPhy, isRelevantToPageContext, matchesAllWordsInAnyOrder, pushConceptsToHistory, searchResultIsPublic, shortcuts, TAG_ID, tags} from "../../services";
6+
import {getFilteredStageOptions, isAda, isPhy, isRelevantToPageContext, matchesAllWordsInAnyOrder, pushConceptsToHistory, searchResultIsPublic, shortcuts, TAG_ID, tags} from "../../services";
77
import {generateSubjectLandingPageCrumbFromContext, TitleAndBreadcrumb} from "../elements/TitleAndBreadcrumb";
88
import {ShortcutResponse, Tag} from "../../../IsaacAppTypes";
99
import {IsaacSpinner} from "../handlers/IsaacSpinner";
1010
import { ListView } from "../elements/list-groups/ListView";
1111
import { ContentTypeVisibility, LinkToContentSummaryList } from "../elements/list-groups/ContentSummaryListGroupItem";
1212
import { SubjectSpecificConceptListSidebar, MainContent, SidebarLayout, GenericConceptsSidebar } from "../elements/layout/SidebarLayout";
13-
import { isFullyDefinedContext, useUrlPageTheme } from "../../services/pageContext";
13+
import { getHumanContext, isFullyDefinedContext, useUrlPageTheme } from "../../services/pageContext";
1414
import { useListConceptsQuery } from "../../state/slices/api/conceptsApi";
1515
import { ShowLoadingQuery } from "../handlers/ShowLoadingQuery";
16-
import { ContentSummaryDTO } from "../../../IsaacApiTypes";
16+
import { ContentSummaryDTO, Stage } from "../../../IsaacApiTypes";
1717
import { skipToken } from "@reduxjs/toolkit/query";
18+
import { AffixButton } from "../elements/AffixButton";
1819

1920
const subjectToTagMap = {
2021
physics: TAG_ID.physics,
@@ -31,13 +32,17 @@ export const Concepts = withRouter((props: RouteComponentProps) => {
3132

3233
const searchParsed = queryString.parse(location.search, {arrayFormat: "comma"});
3334

34-
const [query, filters] = useMemo(() => {
35+
const [query, filters, stages] = useMemo(() => {
3536
const queryParsed = searchParsed.query || null;
3637
const query = Array.isArray(queryParsed) ? queryParsed.join(",") : queryParsed;
3738

3839
const filterParsed = searchParsed.types || null;
3940
const filters = Array.isArray(filterParsed) ? filterParsed.filter(x => !!x) as string[] : filterParsed?.split(",") ?? [];
40-
return [query, filters];
41+
42+
const stagesParsed = searchParsed.stages || null;
43+
const stages = Array.isArray(stagesParsed) ? stagesParsed.filter(x => !!x) as string[] : stagesParsed?.split(",") ?? [];
44+
45+
return [query, filters, stages];
4146
}, [searchParsed]);
4247

4348
const applicableTags = pageContext?.subject
@@ -48,21 +53,22 @@ export const Concepts = withRouter((props: RouteComponentProps) => {
4853
const [conceptFilters, setConceptFilters] = useState<Tag[]>(
4954
applicableTags.filter(f => filters.includes(f.id))
5055
);
56+
const [searchStages, setSearchStages] = useState<Stage[]>(getFilteredStageOptions().filter(s => stages.includes(s.value)).map(s => s.value));
5157
const [shortcutResponse, setShortcutResponse] = useState<ShortcutResponse[]>();
5258

5359
const listConceptsQuery = useListConceptsQuery(pageContext
5460
? {conceptIds: undefined, tagIds: pageContext?.subject ?? tags.allSubjectTags.map(t => t.id).join(",")}
5561
: skipToken
5662
);
5763

58-
const shortcutAndFilter = (concepts?: ContentSummaryDTO[], excludeTopicFiltering?: boolean) => {
64+
const shortcutAndFilter = (concepts?: ContentSummaryDTO[], excludeTopicFiltering?: boolean, excludeStageFiltering?: boolean) => {
5965
const searchResults = concepts?.filter(c =>
60-
matchesAllWordsInAnyOrder(c.title, searchText || "") ||
61-
matchesAllWordsInAnyOrder(c.summary, searchText || "")
66+
(matchesAllWordsInAnyOrder(c.title, searchText || "") || matchesAllWordsInAnyOrder(c.summary, searchText || ""))
6267
);
6368

6469
const filteredSearchResults = searchResults
6570
?.filter((result) => excludeTopicFiltering || !filters.length || result?.tags?.some(t => filters.includes(t)))
71+
.filter((result) => excludeStageFiltering || !searchStages.length || searchStages.some(s => result.audience?.some(a => a.stage?.includes(s))))
6672
.filter((result) => !pageContext?.stage?.length || isRelevantToPageContext(result.audience, pageContext))
6773
.filter((result) => searchResultIsPublic(result, user));
6874

@@ -77,14 +83,20 @@ export const Concepts = withRouter((props: RouteComponentProps) => {
7783
].reduce((acc, t) => ({
7884
...acc,
7985
// we exclude topics when filtering here to avoid selecting a filter changing the tag counts
80-
[t.id]: shortcutAndFilter(listConceptsQuery?.data?.results, true)?.filter(c => c.tags?.includes(t.id)).length || 0
86+
[t.id]: shortcutAndFilter(listConceptsQuery?.data?.results, true, false)?.filter(c => c.tags?.includes(t.id)).length || 0
87+
}), {});
88+
89+
const stageCounts = getFilteredStageOptions().reduce((acc, s) => ({
90+
...acc,
91+
// we exclude stages when filtering here to avoid selecting a filter changing the tag counts
92+
[s.value]: shortcutAndFilter(listConceptsQuery?.data?.results, false, true)?.filter(c => c.audience?.some(a => a.stage?.includes(s.value)))?.length || 0
8193
}), {});
8294

8395
function doSearch(e?: FormEvent<HTMLFormElement>) {
8496
if (e) {
8597
e.preventDefault();
8698
}
87-
pushConceptsToHistory(history, searchText || "", [...conceptFilters.map(f => f.id)]);
99+
pushConceptsToHistory(history, searchText || "", [...conceptFilters.map(f => f.id)], searchStages);
88100

89101
if (searchText) {
90102
setShortcutResponse(shortcuts(searchText));
@@ -101,7 +113,7 @@ export const Concepts = withRouter((props: RouteComponentProps) => {
101113
};
102114
}, [searchText]);
103115

104-
useEffect(() => {doSearch();}, [conceptFilters]);
116+
useEffect(() => {doSearch();}, [conceptFilters, searchStages]);
105117

106118
const crumb = isPhy && isFullyDefinedContext(pageContext) && generateSubjectLandingPageCrumbFromContext(pageContext);
107119

@@ -117,9 +129,20 @@ export const Concepts = withRouter((props: RouteComponentProps) => {
117129
<SidebarLayout>
118130
{pageContext?.subject
119131
? <SubjectSpecificConceptListSidebar {...sidebarProps}/>
120-
: <GenericConceptsSidebar {...sidebarProps}/>
132+
: <GenericConceptsSidebar {...sidebarProps} searchStages={searchStages} setSearchStages={setSearchStages} stageCounts={stageCounts}/>
121133
}
122134
<MainContent>
135+
{pageContext?.subject && <div className="d-flex align-items-baseline flex-wrap flex-md-nowrap flex-lg-wrap flex-xl-nowrap mt-3">
136+
<p className="me-3">The concepts shown on this page have been filtered to only show those that are relevant to {getHumanContext(pageContext)}.</p>
137+
<AffixButton size="md" color="keyline" tag={Link} to="/concepts" className="ms-auto"
138+
affix={{
139+
affix: "icon-right",
140+
position: "suffix",
141+
type: "icon"
142+
}}>
143+
Browse all concepts
144+
</AffixButton>
145+
</div>}
123146
{isPhy && <div className="list-results-container p-2 my-4">
124147
<ShowLoadingQuery
125148
query={listConceptsQuery}

src/app/components/pages/QuestionFinder.tsx

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,8 @@ import { PageFragment } from "../elements/PageFragment";
5252
import { RenderNothing } from "../elements/RenderNothing";
5353
import { processTagHierarchy, pruneTreeNode } from "../../services/questionHierarchy";
5454
import { SearchInputWithIcon } from "../elements/SearchInputs";
55+
import { AffixButton } from "../elements/AffixButton";
56+
import { Link } from "react-router-dom";
5557
import { updateTopicChoices } from "../../services";
5658

5759
// Type is used to ensure that we check all query params if a new one is added in the future
@@ -459,10 +461,20 @@ export const QuestionFinder = withRouter(({location}: RouteComponentProps) => {
459461

460462
{siteSpecific(
461463
<div className="my-3">
462-
{(pageContext?.subject && pageContext.stage
463-
? `Use our question finder to find questions to try on ${getHumanContext(pageContext)} topics.`
464-
: "Use our question finder to find questions to try on topics in Physics, Maths, Chemistry and Biology."
465-
) + " Use our practice questions to become fluent in topics and then take your understanding and problem solving skills to the next level with our challenge questions."}
464+
{(pageContext?.subject && pageContext.stage)
465+
? <div className="d-flex align-items-baseline flex-wrap flex-md-nowrap flex-lg-wrap flex-xl-nowrap">
466+
<p className="me-3">The questions shown on this page have been filtered to only show those that are relevant to {getHumanContext(pageContext)}.</p>
467+
<AffixButton size="md" color="keyline" tag={Link} to="/questions" className="ms-auto"
468+
affix={{
469+
affix: "icon-right",
470+
position: "suffix",
471+
type: "icon"
472+
}}>
473+
Browse all questions
474+
</AffixButton>
475+
</div>
476+
: <>Use our question finder to find questions to try on topics in Physics, Maths, Chemistry and Biology.
477+
Use our practice questions to become fluent in topics and then take your understanding and problem solving skills to the next level with our challenge questions.</>}
466478
</div>,
467479
<PageFragment fragmentId={"question_finder_intro"} ifNotFound={RenderNothing} />
468480
)}

0 commit comments

Comments
 (0)