Skip to content

Previous page context overhaul #1341

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 13 commits into from
Mar 12, 2025
Merged
Show file tree
Hide file tree
Changes from all 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
1 change: 1 addition & 0 deletions src/IsaacAppTypes.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -755,4 +755,5 @@ export type QuestionCorrectness = "CORRECT" | "INCORRECT" | "NOT_ANSWERED" | "NO
export type PageContextState = {
stage?: LearningStage[];
subject?: Subject;
previousContext?: Omit<PageContextState, "previousContext">;
} | null | undefined;
144 changes: 117 additions & 27 deletions src/app/components/elements/layout/SidebarLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,20 +3,20 @@ import { Col, ColProps, RowProps, Input, Offcanvas, OffcanvasBody, OffcanvasHead
import partition from "lodash/partition";
import classNames from "classnames";
import { AssignmentDTO, ContentSummaryDTO, IsaacConceptPageDTO, QuestionDTO, QuizAttemptDTO, RegisteredUserDTO } from "../../../../IsaacApiTypes";
import { above, ACCOUNT_TAB, ACCOUNT_TABS, AUDIENCE_DISPLAY_FIELDS, below, BOARD_ORDER_NAMES, BoardCompletions, BoardCreators, BoardLimit, BoardSubjects, BoardViews, confirmThen, determineAudienceViews, filterAssignmentsByStatus, filterAudienceViewsByProperties, getDistinctAssignmentGroups, getDistinctAssignmentSetters, getThemeFromContextAndTags, HUMAN_STAGES, ifKeyIsEnter, isAda, isDefined, siteSpecific, useDeviceSize } from "../../../services";
import { above, ACCOUNT_TAB, ACCOUNT_TABS, AUDIENCE_DISPLAY_FIELDS, below, BOARD_ORDER_NAMES, BoardCompletions, BoardCreators, BoardLimit, BoardSubjects, BoardViews, confirmThen, determineAudienceViews, filterAssignmentsByStatus, filterAudienceViewsByProperties, getDistinctAssignmentGroups, getDistinctAssignmentSetters, getHumanContext, getThemeFromContextAndTags, HUMAN_STAGES, ifKeyIsEnter, isAda, isDefined, PHY_NAV_SUBJECTS, siteSpecific, TAG_ID, tags, useDeviceSize } from "../../../services";
import { StageAndDifficultySummaryIcons } from "../StageAndDifficultySummaryIcons";
import { selectors, useAppSelector } from "../../../state";
import { Link, useHistory } from "react-router-dom";
import { AppGroup, AssignmentBoardOrder, Tag } from "../../../../IsaacAppTypes";
import { AffixButton } from "../AffixButton";
import { getHumanContext } from "../../../services/pageContext";
import { QuestionFinderFilterPanel, QuestionFinderFilterPanelProps } from "../panels/QuestionFinderFilterPanel";
import { AssignmentState } from "../../pages/MyAssignments";
import { ShowLoadingQuery } from "../../handlers/ShowLoadingQuery";
import { Spacer } from "../Spacer";
import { StyledTabPicker } from "../inputs/StyledTabPicker";
import { GroupSelector } from "../../pages/Groups";
import { QuizRubricButton, SectionProgress } from "../quiz/QuizAttemptComponent";
import { StyledCheckbox } from "../inputs/StyledCheckbox";
import { formatISODateOnly } from "../DateString";

export const SidebarLayout = (props: RowProps) => {
Expand All @@ -29,11 +29,12 @@ export const MainContent = (props: ColProps) => {
return siteSpecific(<Col xs={12} lg={8} xl={9} {...rest} className={classNames(className, "order-0 order-lg-1")} />, props.children);
};

const QuestionLink = (props: React.HTMLAttributes<HTMLLIElement> & {question: QuestionDTO, sidebarRef: RefObject<HTMLDivElement>}) => {
const { question, sidebarRef, ...rest } = props;
const QuestionLink = (props: React.HTMLAttributes<HTMLLIElement> & {question: QuestionDTO}) => {
const { question, ...rest } = props;
const subject = useAppSelector(selectors.pageContext.subject);
const audienceFields = filterAudienceViewsByProperties(determineAudienceViews(question.audience), AUDIENCE_DISPLAY_FIELDS);

return <li key={question.id} {...rest} data-bs-theme={getThemeFromContextAndTags(sidebarRef, question.tags ?? [])}>
return <li key={question.id} {...rest} data-bs-theme={getThemeFromContextAndTags(subject, question.tags ?? [])}>
<Link to={`/questions/${question.id}`} className="py-2">
<i className="icon icon-question"/>
<div className="d-flex flex-column w-100">
Expand All @@ -44,10 +45,11 @@ const QuestionLink = (props: React.HTMLAttributes<HTMLLIElement> & {question: Qu
</li>;
};

const ConceptLink = (props: React.HTMLAttributes<HTMLLIElement> & {concept: IsaacConceptPageDTO, sidebarRef: RefObject<HTMLDivElement>}) => {
const { concept, sidebarRef, ...rest } = props;
const ConceptLink = (props: React.HTMLAttributes<HTMLLIElement> & {concept: IsaacConceptPageDTO}) => {
const { concept, ...rest } = props;
const subject = useAppSelector(selectors.pageContext.subject);

return <li key={concept.id} {...rest} data-bs-theme={getThemeFromContextAndTags(sidebarRef, concept.tags ?? [])}>
return <li key={concept.id} {...rest} data-bs-theme={getThemeFromContextAndTags(subject, concept.tags ?? [])}>
<Link to={`/concepts/${concept.id}`} className="py-2">
<i className="icon icon-lightbulb"/>
<span className="hover-underline link-title">{concept.title}</span>
Expand Down Expand Up @@ -141,7 +143,7 @@ export const QuestionSidebar = (props: QuestionSidebarProps) => {
<div className="section-divider"/>
<h5>Related concepts</h5>
<ul className="link-list">
{relatedConcepts.map((concept, i) => <ConceptLink key={i} concept={concept} sidebarRef={sidebarRef} />)}
{relatedConcepts.map((concept, i) => <ConceptLink key={i} concept={concept} />)}
</ul>
</>}
{relatedQuestions && relatedQuestions.length > 0 && <>
Expand All @@ -150,19 +152,19 @@ export const QuestionSidebar = (props: QuestionSidebarProps) => {
<div className="section-divider"/>
<h5>Related questions</h5>
<ul className="link-list">
{relatedQuestions.map((question, i) => <QuestionLink key={i} sidebarRef={sidebarRef} question={question} />)}
{relatedQuestions.map((question, i) => <QuestionLink key={i} question={question} />)}
</ul>
</>
: <>
<div className="section-divider"/>
<h5>Related {HUMAN_STAGES[pageContextStage[0]]} questions</h5>
<ul className="link-list">
{relatedQuestionsForContextStage.map((question, i) => <QuestionLink key={i} sidebarRef={sidebarRef} question={question} />)}
{relatedQuestionsForContextStage.map((question, i) => <QuestionLink key={i} question={question} />)}
</ul>
<div className="section-divider"/>
<h5>Related questions for other learning stages</h5>
<ul className="link-list">
{relatedQuestionsForOtherStages.map((question, i) => <QuestionLink key={i} sidebarRef={sidebarRef} question={question} />)}
{relatedQuestionsForOtherStages.map((question, i) => <QuestionLink key={i} question={question} />)}
</ul>
</>
}
Expand All @@ -186,36 +188,53 @@ export const ConceptSidebar = (props: QuestionSidebarProps) => {



interface FilterCheckboxProps extends React.HTMLAttributes<HTMLLabelElement> {
interface FilterCheckboxProps extends React.HTMLAttributes<HTMLElement> {
tag: Tag;
conceptFilters: Tag[];
setConceptFilters: React.Dispatch<React.SetStateAction<Tag[]>>;
tagCounts?: Record<string, number>;
incompatibleTags?: Tag[]; // tags that are removed when this tag is added
dependentTags?: Tag[]; // tags that are removed when this tag is removed
baseTag?: Tag; // tag to add when all tags are removed
checkboxStyle?: "tab" | "button";
bsSize?: "sm" | "lg";
}

const FilterCheckbox = (props : FilterCheckboxProps) => {
const {tag, conceptFilters, setConceptFilters, tagCounts, ...rest} = props;
const {tag, conceptFilters, setConceptFilters, tagCounts, checkboxStyle, incompatibleTags, dependentTags, baseTag, ...rest} = props;
const [checked, setChecked] = useState(conceptFilters.includes(tag));

useEffect(() => {
setChecked(conceptFilters.includes(tag));
}, [conceptFilters, tag]);

return <StyledTabPicker {...rest} id={tag.id} checked={checked}
onInputChange={(e: ChangeEvent<HTMLInputElement>) => setConceptFilters(f => e.target.checked ? [...f, tag] : f.filter(c => c !== tag))}
checkboxTitle={tag.title} count={tagCounts && isDefined(tagCounts[tag.id]) ? tagCounts[tag.id] : undefined}
/>;
const handleCheckboxChange = (checked: boolean) => {
const newConceptFilters = checked
? [...conceptFilters.filter(c => !incompatibleTags?.includes(c)), tag]
: conceptFilters.filter(c => ![tag, ...(dependentTags ?? [])].includes(c));
setConceptFilters(newConceptFilters.length > 0 ? newConceptFilters : (baseTag ? [baseTag] : []));
};

return checkboxStyle === "button"
? <StyledCheckbox {...rest} id={tag.id} checked={checked}
onChange={(e: ChangeEvent<HTMLInputElement>) => handleCheckboxChange(e.target.checked)}
label={<span>{tag.title} {tagCounts && isDefined(tagCounts[tag.id]) && <span className="text-muted">({tagCounts[tag.id]})</span>}</span>}
/>
: <StyledTabPicker {...rest} id={tag.id} checked={checked}
onInputChange={(e: ChangeEvent<HTMLInputElement>) => handleCheckboxChange(e.target.checked)}
checkboxTitle={tag.title} count={tagCounts && isDefined(tagCounts[tag.id]) ? tagCounts[tag.id] : undefined}
/>;
};

const AllFiltersCheckbox = (props: Omit<FilterCheckboxProps, "tag">) => {
const { conceptFilters, setConceptFilters, tagCounts, ...rest } = props;
const [previousFilters, setPreviousFilters] = useState<Tag[]>([]);
const { conceptFilters, setConceptFilters, tagCounts, baseTag, ...rest } = props;
const [previousFilters, setPreviousFilters] = useState<Tag[]>(baseTag ? [baseTag] : []);
return <StyledTabPicker {...rest}
id="all" checked={!conceptFilters.length} checkboxTitle="All" count={tagCounts && Object.values(tagCounts).reduce((a, b) => a + b, 0)}
id="all" checked={baseTag ? conceptFilters.length === 1 && conceptFilters[0] === baseTag : !conceptFilters.length} checkboxTitle="All" count={tagCounts && Object.values(tagCounts).reduce((a, b) => a + b, 0)}
onInputChange={(e) => {
if (e.target.checked) {
setPreviousFilters(conceptFilters);
setConceptFilters([]);
setConceptFilters(baseTag ? [baseTag] : []);
} else {
setConceptFilters(previousFilters);
}
Expand All @@ -237,6 +256,8 @@ export const SubjectSpecificConceptListSidebar = (props: ConceptListSidebarProps

const pageContext = useAppSelector(selectors.pageContext.context);

const subjectTag = tags.getById(pageContext?.subject as TAG_ID);

return <ContentSidebar {...rest}>
<div className="section-divider"/>
<h5>Search concepts</h5>
Expand All @@ -251,9 +272,19 @@ export const SubjectSpecificConceptListSidebar = (props: ConceptListSidebarProps

<div className="d-flex flex-column">
<h5>Filter by topic</h5>
<AllFiltersCheckbox conceptFilters={conceptFilters} setConceptFilters={setConceptFilters} tagCounts={tagCounts} />
<AllFiltersCheckbox conceptFilters={conceptFilters} setConceptFilters={setConceptFilters} tagCounts={tagCounts} baseTag={subjectTag}/>
<div className="section-divider-small"/>
{applicableTags.map(tag => <FilterCheckbox key={tag.id} tag={tag} conceptFilters={conceptFilters} setConceptFilters={setConceptFilters} tagCounts={tagCounts}/>)}
{applicableTags.map(tag =>
<FilterCheckbox
key={tag.id}
tag={tag}
conceptFilters={conceptFilters}
setConceptFilters={setConceptFilters}
tagCounts={tagCounts}
incompatibleTags={[subjectTag]}
baseTag={subjectTag}
/>
)}
</div>

<div className="section-divider"/>
Expand All @@ -272,9 +303,68 @@ export const SubjectSpecificConceptListSidebar = (props: ConceptListSidebarProps
</ContentSidebar>;
};

export const GenericConceptsSidebar = (props: SidebarProps) => {
// TODO
return <ContentSidebar {...props}/>;
export const GenericConceptsSidebar = (props: ConceptListSidebarProps) => {
const { searchText, setSearchText, conceptFilters, setConceptFilters, applicableTags, tagCounts, ...rest } = props;

const pageContext = useAppSelector(selectors.pageContext.context);

return <ContentSidebar {...rest}>
<div className="section-divider"/>
<h5>Search concepts</h5>
<Input
className='search--filter-input my-4'
type="search" value={searchText || ""}
placeholder="e.g. Forces"
onChange={(e: ChangeEvent<HTMLInputElement>) => setSearchText(e.target.value)}
/>

<div className="section-divider"/>

<div className="d-flex flex-column">
<h5>Filter by subject</h5>
{Object.keys(PHY_NAV_SUBJECTS).map((subject, i) => {
const subjectTag = tags.getById(subject as TAG_ID);
const descendentTags = tags.getDirectDescendents(subjectTag.id);
const isSelected = conceptFilters.includes(subjectTag) || descendentTags.some(tag => conceptFilters.includes(tag));
const isPartial = descendentTags.some(tag => conceptFilters.includes(tag)) && descendentTags.some(tag => !conceptFilters.includes(tag));
return <div key={i} className={classNames("ps-2", {"checkbox-region": isSelected})}>
<FilterCheckbox
checkboxStyle="button" color="theme" data-bs-theme={subject} tag={subjectTag} conceptFilters={conceptFilters}
setConceptFilters={setConceptFilters} tagCounts={tagCounts} dependentTags={descendentTags} incompatibleTags={descendentTags}
className={classNames({"icon-checkbox-off": !isSelected, "icon icon-checkbox-partial-alt": isSelected && isPartial, "icon-checkbox-selected": isSelected && !isPartial})}
/>
{isSelected && <div className="ms-3 ps-2">
{descendentTags
.filter(tag => !isDefined(tagCounts) || tagCounts[tag.id] > 0)
// .sort((a, b) => tagCounts ? tagCounts[b.id] - tagCounts[a.id] : 0)
.map((tag, j) => <FilterCheckbox key={j}
checkboxStyle="button" color="theme" bsSize="sm" data-bs-theme={subject} tag={tag} conceptFilters={conceptFilters}
setConceptFilters={setConceptFilters} tagCounts={tagCounts} incompatibleTags={[subjectTag]}
/>)
}
</div>}
</div>;
})}
</div>

<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>;
};

interface QuestionFinderSidebarProps extends SidebarProps {
Expand Down
14 changes: 8 additions & 6 deletions src/app/components/elements/list-groups/ListView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,13 @@ import React from "react";
import { AbstractListViewItem, AbstractListViewItemProps, ListViewTagProps } from "./AbstractListViewItem";
import { ShortcutResponse, ViewingContext } from "../../../../IsaacAppTypes";
import { determineAudienceViews } from "../../../services/userViewingContext";
import { DOCUMENT_TYPE, documentTypePathPrefix, SEARCH_RESULT_TYPE, Subject, TAG_ID, TAG_LEVEL, tags } from "../../../services";
import { DOCUMENT_TYPE, documentTypePathPrefix, getThemeFromContextAndTags, SEARCH_RESULT_TYPE, Subject, TAG_ID, TAG_LEVEL, tags } from "../../../services";
import { ListGroup, ListGroupItem, ListGroupItemProps, ListGroupProps } from "reactstrap";
import { TitleIconProps } from "../PageTitle";
import { AffixButton } from "../AffixButton";
import { QuizSummaryDTO } from "../../../../IsaacApiTypes";
import { Link } from "react-router-dom";
import { showQuizSettingModal, useAppDispatch } from "../../../state";
import { selectors, showQuizSettingModal, useAppDispatch, useAppSelector } from "../../../state";
import classNames from "classnames";

export interface ListViewCardProps extends ListGroupItemProps {
Expand Down Expand Up @@ -37,14 +37,15 @@ export const QuestionListViewItem = (props : QuestionListViewItemProps) => {
const { item, ...rest } = props;
const breadcrumb = tags.getByIdsAsHierarchy((item.tags || []) as TAG_ID[]).map(tag => tag.title);
const audienceViews: ViewingContext[] = determineAudienceViews(item.audience);
const itemSubject = tags.getSpecifiedTag(TAG_LEVEL.subject, item.tags as TAG_ID[])?.id as Subject;
const pageSubject = useAppSelector(selectors.pageContext.subject);
const itemSubject = getThemeFromContextAndTags(pageSubject, tags.getSubjectTags((item.tags || []) as TAG_ID[]).map(t => t.id));
const url = `/${documentTypePathPrefix[DOCUMENT_TYPE.QUESTION]}/${item.id}`;

return <AbstractListViewItem
{...rest}
icon={{type: "hex", icon: "list-icon-question", size: "sm"}}
title={item.title ?? ""}
subject={itemSubject}
subject={itemSubject !== "neutral" ? itemSubject : undefined}
tags={item.tags}
supersededBy={item.supersededBy}
subtitle={item.subtitle}
Expand All @@ -56,13 +57,14 @@ export const QuestionListViewItem = (props : QuestionListViewItemProps) => {
};

export const ConceptListViewItem = ({item, ...rest}: {item: ShortcutResponse}) => {
const itemSubject = tags.getSpecifiedTag(TAG_LEVEL.subject, item.tags as TAG_ID[])?.id as Subject;
const pageSubject = useAppSelector(selectors.pageContext.subject);
const itemSubject = getThemeFromContextAndTags(pageSubject, tags.getSubjectTags((item.tags || []) as TAG_ID[]).map(t => t.id));
const url = `/${documentTypePathPrefix[DOCUMENT_TYPE.CONCEPT]}/${item.id}`;

return <AbstractListViewItem
icon={{type: "hex", icon: "list-icon-concept", size: "sm"}}
title={item.title ?? ""}
subject={itemSubject}
subject={itemSubject !== "neutral" ? itemSubject : undefined}
subtitle={item.subtitle}
url={url}
{...rest}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -220,7 +220,7 @@ export function QuestionFinderFilterPanel(props: QuestionFinderFilterPanelProps)
toggle={() => listStateDispatch({type: "toggle", id: "stage", focus: below["md"](deviceSize)})}
numberSelected={(isAda && searchStages.includes(STAGE.ALL)) ? searchStages.length - 1 : searchStages.length}
>
{getFilteredStageOptions().filter(stage => pageStageToSearchStage(pageContext?.stage).includes(stage.value) || !pageContext?.stage).map((stage, index) => (
{getFilteredStageOptions().filter(stage => pageStageToSearchStage(pageContext?.stage).includes(stage.value) || !pageContext?.stage?.length).map((stage, index) => (
<div className={classNames("w-100 ps-3 py-1", {"bg-white": isAda, "ms-2": isPhy, "checkbox-region": isPhy && searchStages.includes(stage.value)})} key={index}>
<StyledCheckbox
color="primary"
Expand Down
Loading
Loading