Skip to content

Redesign: concept finder improvements #1463

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 20 commits into from
May 23, 2025
Merged
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
96 changes: 31 additions & 65 deletions src/app/components/elements/layout/SidebarLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -335,9 +335,13 @@ const FilterCheckbox = (props : FilterCheckboxProps) => {
}, [conceptFilters, tag]);

const handleCheckboxChange = (checked: boolean) => {
// Reselect parent if all children are deselected
const siblingTags = tag.type === "field" && incompatibleTags ? tags.getDirectDescendents(incompatibleTags[0].id).filter(t => t !== tag) : [];
const reselectParent = siblingTags.length && !siblingTags.some(t => conceptFilters.includes(t));

const newConceptFilters = checked
? [...conceptFilters.filter(c => !incompatibleTags?.includes(c)), ...(!partiallySelected ? [tag] : [])]
: conceptFilters.filter(c => ![tag, ...(dependentTags ?? [])].includes(c));
: [...conceptFilters.filter(c => ![tag, ...(dependentTags ?? [])].includes(c)), ...(reselectParent ? [incompatibleTags![0]] : [])];
setConceptFilters(newConceptFilters.length > 0 ? newConceptFilters : (baseTag ? [baseTag] : []));
};

Expand Down Expand Up @@ -426,28 +430,26 @@ export const SubjectSpecificConceptListSidebar = (props: ConceptListSidebarProps
}
</div>
</search>

<div className="section-divider"/>

<div className="sidebar-help">
<p>The concepts shown on this page have been filtered to only show those that are relevant to {getHumanContext(pageContext)}.</p>
<p>If you want to explore broader concepts across multiple subjects or learning stages, you can use the main concept browser:</p>
<AffixButton size="md" color="keyline" tag={Link} to="/concepts" affix={{
affix: "icon-right",
position: "suffix",
type: "icon"
}}>
Browse concepts
</AffixButton>
</div>
</ContentSidebar>;
};

export const GenericConceptsSidebar = (props: ConceptListSidebarProps) => {
const { searchText, setSearchText, conceptFilters, setConceptFilters, applicableTags, tagCounts, ...rest } = props;
interface GenericConceptsSidebarProps extends ConceptListSidebarProps {
searchStages: Stage[];
setSearchStages: React.Dispatch<React.SetStateAction<Stage[]>>;
stageCounts: Record<string, number>;
}

const pageContext = useAppSelector(selectors.pageContext.context);
export const GenericConceptsSidebar = (props: GenericConceptsSidebarProps) => {
const { searchText, setSearchText, conceptFilters, setConceptFilters, applicableTags, tagCounts, searchStages, setSearchStages, stageCounts, ...rest } = props;

const updateSearchStages = (stage: Stage) => {
if (searchStages.includes(stage)) {
setSearchStages(searchStages.filter(s => s !== stage));
} else {
setSearchStages([...(searchStages ?? []), stage]);
}
};

return <ContentSidebar {...rest}>
<div className="section-divider"/>
<search>
Expand Down Expand Up @@ -487,26 +489,20 @@ export const GenericConceptsSidebar = (props: ConceptListSidebarProps) => {
</div>}
</div>;
})}
<div className="section-divider"/>
<h5>Filter by stage</h5>
<ul className="ps-2">
{getFilteredStageOptions().map((stage, i) =>
<li key={i}>
<StyledCheckbox checked={searchStages.includes(stage.value)}
label={<>{stage.label} <span className="text-muted">({stageCounts[stage.value]})</span></>}
data-bs-theme={conceptFilters.length === 1 ? conceptFilters[0].id : undefined}
color="theme" onChange={() => {updateSearchStages(stage.value);}}/>
</li>)}
</ul>
</div>
</search>

<div className="section-divider"/>

{pageContext?.subject && <>
<div className="section-divider"/>

<div className="sidebar-help">
<p>The concepts shown on this page have been filtered to only show those that are relevant to {getHumanContext(pageContext)}.</p>
<p>If you want to explore broader concepts across multiple subjects or learning stages, you can use the main concept browser:</p>
<AffixButton size="md" color="keyline" tag={Link} to="/concepts" affix={{
affix: "icon-right",
position: "suffix",
type: "icon"
}}>
Browse concepts
</AffixButton>
</div>
</>}
</ContentSidebar>;
};

Expand Down Expand Up @@ -541,22 +537,6 @@ export const QuestionFinderSidebar = (props: QuestionFinderSidebarProps) => {

<QuestionFinderFilterPanel {...questionFinderFilterPanelProps} />
</search>

{pageContext?.subject && pageContext?.stage && <>
<div className="section-divider"/>

<div className="sidebar-help">
<p>The questions shown here have been filtered to only show those that are relevant to {getHumanContext(pageContext)}.</p>
<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>
<AffixButton size="md" color="keyline" tag={Link} to="/questions" affix={{
affix: "icon-right",
position: "suffix",
type: "icon"
}}>
Browse all questions
</AffixButton>
</div>
</>}
</ContentSidebar>;
};

Expand Down Expand Up @@ -660,20 +640,6 @@ export const PracticeQuizzesSidebar = (props: PracticeQuizzesSidebarProps) => {
</ul>
</>}

{isFullyDefinedContext(pageContext) && <>
<div className="section-divider"/>
<div className="sidebar-help">
<p>The practice tests shown here have been filtered to only show those that are relevant to {getHumanContext(pageContext)}.</p>
<p>If you want to explore our full range of practice tests, you can view the main practice tests page:</p>
<AffixButton size="md" color="keyline" tag={Link} to="/practice_tests" affix={{
affix: "icon-right",
position: "suffix",
type: "icon"
}}>
Browse all practice tests
</AffixButton>
</div>
</>}
<div className="section-divider"/>
<div className="sidebar-help">
<p>You can see all of the tests that you have in progress or have completed in your My Isaac:</p>
Expand Down
46 changes: 35 additions & 11 deletions src/app/components/pages/Concepts.tsx
Original file line number Diff line number Diff line change
@@ -1,20 +1,21 @@
import React, {FormEvent, MutableRefObject, useEffect, useMemo, useRef, useState} from "react";
import {RouteComponentProps, withRouter} from "react-router-dom";
import {Link, RouteComponentProps, withRouter} from "react-router-dom";
import {selectors, useAppSelector} from "../../state";
import {Badge, Card, CardBody, CardHeader, Container} from "reactstrap";
import queryString from "query-string";
import {isAda, isPhy, isRelevantToPageContext, matchesAllWordsInAnyOrder, pushConceptsToHistory, searchResultIsPublic, shortcuts, TAG_ID, tags} from "../../services";
import {getFilteredStageOptions, isAda, isPhy, isRelevantToPageContext, matchesAllWordsInAnyOrder, pushConceptsToHistory, searchResultIsPublic, shortcuts, TAG_ID, tags} from "../../services";
import {generateSubjectLandingPageCrumbFromContext, TitleAndBreadcrumb} from "../elements/TitleAndBreadcrumb";
import {ShortcutResponse, Tag} from "../../../IsaacAppTypes";
import {IsaacSpinner} from "../handlers/IsaacSpinner";
import { ListView } from "../elements/list-groups/ListView";
import { ContentTypeVisibility, LinkToContentSummaryList } from "../elements/list-groups/ContentSummaryListGroupItem";
import { SubjectSpecificConceptListSidebar, MainContent, SidebarLayout, GenericConceptsSidebar } from "../elements/layout/SidebarLayout";
import { isFullyDefinedContext, useUrlPageTheme } from "../../services/pageContext";
import { getHumanContext, isFullyDefinedContext, useUrlPageTheme } from "../../services/pageContext";
import { useListConceptsQuery } from "../../state/slices/api/conceptsApi";
import { ShowLoadingQuery } from "../handlers/ShowLoadingQuery";
import { ContentSummaryDTO } from "../../../IsaacApiTypes";
import { ContentSummaryDTO, Stage } from "../../../IsaacApiTypes";
import { skipToken } from "@reduxjs/toolkit/query";
import { AffixButton } from "../elements/AffixButton";

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

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

const [query, filters] = useMemo(() => {
const [query, filters, stages] = useMemo(() => {
const queryParsed = searchParsed.query || null;
const query = Array.isArray(queryParsed) ? queryParsed.join(",") : queryParsed;

const filterParsed = searchParsed.types || null;
const filters = Array.isArray(filterParsed) ? filterParsed.filter(x => !!x) as string[] : filterParsed?.split(",") ?? [];
return [query, filters];

const stagesParsed = searchParsed.stages || null;
const stages = Array.isArray(stagesParsed) ? stagesParsed.filter(x => !!x) as string[] : stagesParsed?.split(",") ?? [];

return [query, filters, stages];
}, [searchParsed]);

const applicableTags = pageContext?.subject
Expand All @@ -48,6 +53,7 @@ export const Concepts = withRouter((props: RouteComponentProps) => {
const [conceptFilters, setConceptFilters] = useState<Tag[]>(
applicableTags.filter(f => filters.includes(f.id))
);
const [searchStages, setSearchStages] = useState<Stage[]>(stages as Stage[]);
const [shortcutResponse, setShortcutResponse] = useState<ShortcutResponse[]>();

const listConceptsQuery = useListConceptsQuery(pageContext
Expand All @@ -57,8 +63,8 @@ export const Concepts = withRouter((props: RouteComponentProps) => {

const shortcutAndFilter = (concepts?: ContentSummaryDTO[], excludeTopicFiltering?: boolean) => {
const searchResults = concepts?.filter(c =>
matchesAllWordsInAnyOrder(c.title, searchText || "") ||
matchesAllWordsInAnyOrder(c.summary, searchText || "")
(matchesAllWordsInAnyOrder(c.title, searchText || "") || matchesAllWordsInAnyOrder(c.summary, searchText || ""))
&& (searchStages.length === 0 || searchStages.some(s => c.audience?.some(a => a.stage?.includes(s))))
);

const filteredSearchResults = searchResults
Expand All @@ -80,11 +86,17 @@ export const Concepts = withRouter((props: RouteComponentProps) => {
[t.id]: shortcutAndFilter(listConceptsQuery?.data?.results, true)?.filter(c => c.tags?.includes(t.id)).length || 0
}), {});

const stageCounts = getFilteredStageOptions().reduce((acc, s) => ({
...acc,
[s.value]: listConceptsQuery?.data?.results?.filter(c => c.audience?.some(a => a.stage?.includes(s.value))
&& (!filters.length || c.tags?.some(t => filters.includes(t))))?.length || 0
}), {});

function doSearch(e?: FormEvent<HTMLFormElement>) {
if (e) {
e.preventDefault();
}
pushConceptsToHistory(history, searchText || "", [...conceptFilters.map(f => f.id)]);
pushConceptsToHistory(history, searchText || "", [...conceptFilters.map(f => f.id)], searchStages);

if (searchText) {
setShortcutResponse(shortcuts(searchText));
Expand All @@ -101,7 +113,7 @@ export const Concepts = withRouter((props: RouteComponentProps) => {
};
}, [searchText]);

useEffect(() => {doSearch();}, [conceptFilters]);
useEffect(() => {doSearch();}, [conceptFilters, searchStages]);

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

Expand All @@ -113,13 +125,25 @@ export const Concepts = withRouter((props: RouteComponentProps) => {
currentPageTitle="Concepts"
intermediateCrumbs={crumb ? [crumb] : undefined}
icon={{type: "hex", icon: "icon-concept"}}
className="mb-4"
/>
<SidebarLayout>
{pageContext?.subject
? <SubjectSpecificConceptListSidebar {...sidebarProps}/>
: <GenericConceptsSidebar {...sidebarProps}/>
: <GenericConceptsSidebar {...sidebarProps} searchStages={searchStages} setSearchStages={setSearchStages} stageCounts={stageCounts}/>
}
<MainContent>
{pageContext?.subject && <div className="d-flex align-items-baseline flex-wrap flex-md-nowrap">
<p className="me-3 mt-2">The concepts shown on this page have been filtered to only show those that are relevant to {getHumanContext(pageContext)}.</p>
<AffixButton size="md" color="keyline" tag={Link} to="/concepts" className="ms-auto" style={{minWidth: "136px"}}
affix={{
affix: "icon-right",
position: "suffix",
type: "icon"
}}>
Browse all concepts
</AffixButton>
</div>}
{isPhy && <div className="list-results-container p-2 my-4">
<ShowLoadingQuery
query={listConceptsQuery}
Expand Down
20 changes: 16 additions & 4 deletions src/app/components/pages/QuestionFinder.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,8 @@ import { PageFragment } from "../elements/PageFragment";
import { RenderNothing } from "../elements/RenderNothing";
import { processTagHierarchy, pruneTreeNode } from "../../services/questionHierarchy";
import { SearchInputWithIcon } from "../elements/SearchInputs";
import { AffixButton } from "../elements/AffixButton";
import { Link } from "react-router-dom";
import { updateTopicChoices } from "../../services";

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

{siteSpecific(
<div className="my-3">
{(pageContext?.subject && pageContext.stage
? `Use our question finder to find questions to try on ${getHumanContext(pageContext)} topics.`
: "Use our question finder to find questions to try on topics in Physics, Maths, Chemistry and Biology."
) + " 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."}
{(pageContext?.subject && pageContext.stage)
? <div className="d-flex align-items-baseline flex-wrap flex-md-nowrap">
<p className="me-3 mt-2">The questions shown on this page have been filtered to only show those that are relevant to {getHumanContext(pageContext)}.</p>
<AffixButton size="md" color="keyline" tag={Link} to="/questions" className="ms-auto" style={{minWidth: "136px"}}
affix={{
affix: "icon-right",
position: "suffix",
type: "icon"
}}>
Browse all questions
</AffixButton>
</div>
: <>Use our question finder to find questions to try on topics in Physics, Maths, Chemistry and Biology.
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.</>}
</div>,
<PageFragment fragmentId={"question_finder_intro"} ifNotFound={RenderNothing} />
)}
Expand Down
5 changes: 3 additions & 2 deletions src/app/services/search.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import {History} from "history";
import {DOCUMENT_TYPE, isStaff, TAG_ID} from ".";
import {ContentSummaryDTO} from "../../IsaacApiTypes";
import {ContentSummaryDTO, Stage} from "../../IsaacApiTypes";
import {PotentialUser} from "../../IsaacAppTypes";
import queryString from "query-string";
import {Immutable} from "immer";
Expand All @@ -18,10 +18,11 @@ export const pushSearchToHistory = function(history: History, searchQuery: strin
});
};

export const pushConceptsToHistory = function(history: History, searchText: string, subjects: TAG_ID[]) {
export const pushConceptsToHistory = function(history: History, searchText: string, subjects: TAG_ID[], stages: Stage[]) {
const queryOptions = {
"query": encodeURIComponent(searchText),
"types": subjects.join(","),
"stages": stages.join(","),
};
history.push({
pathname: history.location.pathname,
Expand Down
Loading