Skip to content

Commit febc870

Browse files
authored
Merge pull request #1341 from isaacphysics/redesign/previous-context-overhaul
Previous page context overhaul
2 parents aabaa05 + 227fc01 commit febc870

File tree

14 files changed

+238
-108
lines changed

14 files changed

+238
-108
lines changed

src/IsaacAppTypes.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -755,4 +755,5 @@ export type QuestionCorrectness = "CORRECT" | "INCORRECT" | "NOT_ANSWERED" | "NO
755755
export type PageContextState = {
756756
stage?: LearningStage[];
757757
subject?: Subject;
758+
previousContext?: Omit<PageContextState, "previousContext">;
758759
} | null | undefined;

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

Lines changed: 117 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -3,20 +3,20 @@ import { Col, ColProps, RowProps, Input, Offcanvas, OffcanvasBody, OffcanvasHead
33
import partition from "lodash/partition";
44
import classNames from "classnames";
55
import { AssignmentDTO, ContentSummaryDTO, IsaacConceptPageDTO, QuestionDTO, QuizAttemptDTO, RegisteredUserDTO } from "../../../../IsaacApiTypes";
6-
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";
6+
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";
77
import { StageAndDifficultySummaryIcons } from "../StageAndDifficultySummaryIcons";
88
import { selectors, useAppSelector } from "../../../state";
99
import { Link, useHistory } from "react-router-dom";
1010
import { AppGroup, AssignmentBoardOrder, Tag } from "../../../../IsaacAppTypes";
1111
import { AffixButton } from "../AffixButton";
12-
import { getHumanContext } from "../../../services/pageContext";
1312
import { QuestionFinderFilterPanel, QuestionFinderFilterPanelProps } from "../panels/QuestionFinderFilterPanel";
1413
import { AssignmentState } from "../../pages/MyAssignments";
1514
import { ShowLoadingQuery } from "../../handlers/ShowLoadingQuery";
1615
import { Spacer } from "../Spacer";
1716
import { StyledTabPicker } from "../inputs/StyledTabPicker";
1817
import { GroupSelector } from "../../pages/Groups";
1918
import { QuizRubricButton, SectionProgress } from "../quiz/QuizAttemptComponent";
19+
import { StyledCheckbox } from "../inputs/StyledCheckbox";
2020
import { formatISODateOnly } from "../DateString";
2121

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

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

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

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

50-
return <li key={concept.id} {...rest} data-bs-theme={getThemeFromContextAndTags(sidebarRef, concept.tags ?? [])}>
52+
return <li key={concept.id} {...rest} data-bs-theme={getThemeFromContextAndTags(subject, concept.tags ?? [])}>
5153
<Link to={`/concepts/${concept.id}`} className="py-2">
5254
<i className="icon icon-lightbulb"/>
5355
<span className="hover-underline link-title">{concept.title}</span>
@@ -141,7 +143,7 @@ export const QuestionSidebar = (props: QuestionSidebarProps) => {
141143
<div className="section-divider"/>
142144
<h5>Related concepts</h5>
143145
<ul className="link-list">
144-
{relatedConcepts.map((concept, i) => <ConceptLink key={i} concept={concept} sidebarRef={sidebarRef} />)}
146+
{relatedConcepts.map((concept, i) => <ConceptLink key={i} concept={concept} />)}
145147
</ul>
146148
</>}
147149
{relatedQuestions && relatedQuestions.length > 0 && <>
@@ -150,19 +152,19 @@ export const QuestionSidebar = (props: QuestionSidebarProps) => {
150152
<div className="section-divider"/>
151153
<h5>Related questions</h5>
152154
<ul className="link-list">
153-
{relatedQuestions.map((question, i) => <QuestionLink key={i} sidebarRef={sidebarRef} question={question} />)}
155+
{relatedQuestions.map((question, i) => <QuestionLink key={i} question={question} />)}
154156
</ul>
155157
</>
156158
: <>
157159
<div className="section-divider"/>
158160
<h5>Related {HUMAN_STAGES[pageContextStage[0]]} questions</h5>
159161
<ul className="link-list">
160-
{relatedQuestionsForContextStage.map((question, i) => <QuestionLink key={i} sidebarRef={sidebarRef} question={question} />)}
162+
{relatedQuestionsForContextStage.map((question, i) => <QuestionLink key={i} question={question} />)}
161163
</ul>
162164
<div className="section-divider"/>
163165
<h5>Related questions for other learning stages</h5>
164166
<ul className="link-list">
165-
{relatedQuestionsForOtherStages.map((question, i) => <QuestionLink key={i} sidebarRef={sidebarRef} question={question} />)}
167+
{relatedQuestionsForOtherStages.map((question, i) => <QuestionLink key={i} question={question} />)}
166168
</ul>
167169
</>
168170
}
@@ -186,36 +188,53 @@ export const ConceptSidebar = (props: QuestionSidebarProps) => {
186188

187189

188190

189-
interface FilterCheckboxProps extends React.HTMLAttributes<HTMLLabelElement> {
191+
interface FilterCheckboxProps extends React.HTMLAttributes<HTMLElement> {
190192
tag: Tag;
191193
conceptFilters: Tag[];
192194
setConceptFilters: React.Dispatch<React.SetStateAction<Tag[]>>;
193195
tagCounts?: Record<string, number>;
196+
incompatibleTags?: Tag[]; // tags that are removed when this tag is added
197+
dependentTags?: Tag[]; // tags that are removed when this tag is removed
198+
baseTag?: Tag; // tag to add when all tags are removed
199+
checkboxStyle?: "tab" | "button";
200+
bsSize?: "sm" | "lg";
194201
}
195202

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

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

204-
return <StyledTabPicker {...rest} id={tag.id} checked={checked}
205-
onInputChange={(e: ChangeEvent<HTMLInputElement>) => setConceptFilters(f => e.target.checked ? [...f, tag] : f.filter(c => c !== tag))}
206-
checkboxTitle={tag.title} count={tagCounts && isDefined(tagCounts[tag.id]) ? tagCounts[tag.id] : undefined}
207-
/>;
211+
const handleCheckboxChange = (checked: boolean) => {
212+
const newConceptFilters = checked
213+
? [...conceptFilters.filter(c => !incompatibleTags?.includes(c)), tag]
214+
: conceptFilters.filter(c => ![tag, ...(dependentTags ?? [])].includes(c));
215+
setConceptFilters(newConceptFilters.length > 0 ? newConceptFilters : (baseTag ? [baseTag] : []));
216+
};
217+
218+
return checkboxStyle === "button"
219+
? <StyledCheckbox {...rest} id={tag.id} checked={checked}
220+
onChange={(e: ChangeEvent<HTMLInputElement>) => handleCheckboxChange(e.target.checked)}
221+
label={<span>{tag.title} {tagCounts && isDefined(tagCounts[tag.id]) && <span className="text-muted">({tagCounts[tag.id]})</span>}</span>}
222+
/>
223+
: <StyledTabPicker {...rest} id={tag.id} checked={checked}
224+
onInputChange={(e: ChangeEvent<HTMLInputElement>) => handleCheckboxChange(e.target.checked)}
225+
checkboxTitle={tag.title} count={tagCounts && isDefined(tagCounts[tag.id]) ? tagCounts[tag.id] : undefined}
226+
/>;
208227
};
209228

210229
const AllFiltersCheckbox = (props: Omit<FilterCheckboxProps, "tag">) => {
211-
const { conceptFilters, setConceptFilters, tagCounts, ...rest } = props;
212-
const [previousFilters, setPreviousFilters] = useState<Tag[]>([]);
230+
const { conceptFilters, setConceptFilters, tagCounts, baseTag, ...rest } = props;
231+
const [previousFilters, setPreviousFilters] = useState<Tag[]>(baseTag ? [baseTag] : []);
213232
return <StyledTabPicker {...rest}
214-
id="all" checked={!conceptFilters.length} checkboxTitle="All" count={tagCounts && Object.values(tagCounts).reduce((a, b) => a + b, 0)}
233+
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)}
215234
onInputChange={(e) => {
216235
if (e.target.checked) {
217236
setPreviousFilters(conceptFilters);
218-
setConceptFilters([]);
237+
setConceptFilters(baseTag ? [baseTag] : []);
219238
} else {
220239
setConceptFilters(previousFilters);
221240
}
@@ -237,6 +256,8 @@ export const SubjectSpecificConceptListSidebar = (props: ConceptListSidebarProps
237256

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

259+
const subjectTag = tags.getById(pageContext?.subject as TAG_ID);
260+
240261
return <ContentSidebar {...rest}>
241262
<div className="section-divider"/>
242263
<h5>Search concepts</h5>
@@ -251,9 +272,19 @@ export const SubjectSpecificConceptListSidebar = (props: ConceptListSidebarProps
251272

252273
<div className="d-flex flex-column">
253274
<h5>Filter by topic</h5>
254-
<AllFiltersCheckbox conceptFilters={conceptFilters} setConceptFilters={setConceptFilters} tagCounts={tagCounts} />
275+
<AllFiltersCheckbox conceptFilters={conceptFilters} setConceptFilters={setConceptFilters} tagCounts={tagCounts} baseTag={subjectTag}/>
255276
<div className="section-divider-small"/>
256-
{applicableTags.map(tag => <FilterCheckbox key={tag.id} tag={tag} conceptFilters={conceptFilters} setConceptFilters={setConceptFilters} tagCounts={tagCounts}/>)}
277+
{applicableTags.map(tag =>
278+
<FilterCheckbox
279+
key={tag.id}
280+
tag={tag}
281+
conceptFilters={conceptFilters}
282+
setConceptFilters={setConceptFilters}
283+
tagCounts={tagCounts}
284+
incompatibleTags={[subjectTag]}
285+
baseTag={subjectTag}
286+
/>
287+
)}
257288
</div>
258289

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

275-
export const GenericConceptsSidebar = (props: SidebarProps) => {
276-
// TODO
277-
return <ContentSidebar {...props}/>;
306+
export const GenericConceptsSidebar = (props: ConceptListSidebarProps) => {
307+
const { searchText, setSearchText, conceptFilters, setConceptFilters, applicableTags, tagCounts, ...rest } = props;
308+
309+
const pageContext = useAppSelector(selectors.pageContext.context);
310+
311+
return <ContentSidebar {...rest}>
312+
<div className="section-divider"/>
313+
<h5>Search concepts</h5>
314+
<Input
315+
className='search--filter-input my-4'
316+
type="search" value={searchText || ""}
317+
placeholder="e.g. Forces"
318+
onChange={(e: ChangeEvent<HTMLInputElement>) => setSearchText(e.target.value)}
319+
/>
320+
321+
<div className="section-divider"/>
322+
323+
<div className="d-flex flex-column">
324+
<h5>Filter by subject</h5>
325+
{Object.keys(PHY_NAV_SUBJECTS).map((subject, i) => {
326+
const subjectTag = tags.getById(subject as TAG_ID);
327+
const descendentTags = tags.getDirectDescendents(subjectTag.id);
328+
const isSelected = conceptFilters.includes(subjectTag) || descendentTags.some(tag => conceptFilters.includes(tag));
329+
const isPartial = descendentTags.some(tag => conceptFilters.includes(tag)) && descendentTags.some(tag => !conceptFilters.includes(tag));
330+
return <div key={i} className={classNames("ps-2", {"checkbox-region": isSelected})}>
331+
<FilterCheckbox
332+
checkboxStyle="button" color="theme" data-bs-theme={subject} tag={subjectTag} conceptFilters={conceptFilters}
333+
setConceptFilters={setConceptFilters} tagCounts={tagCounts} dependentTags={descendentTags} incompatibleTags={descendentTags}
334+
className={classNames({"icon-checkbox-off": !isSelected, "icon icon-checkbox-partial-alt": isSelected && isPartial, "icon-checkbox-selected": isSelected && !isPartial})}
335+
/>
336+
{isSelected && <div className="ms-3 ps-2">
337+
{descendentTags
338+
.filter(tag => !isDefined(tagCounts) || tagCounts[tag.id] > 0)
339+
// .sort((a, b) => tagCounts ? tagCounts[b.id] - tagCounts[a.id] : 0)
340+
.map((tag, j) => <FilterCheckbox key={j}
341+
checkboxStyle="button" color="theme" bsSize="sm" data-bs-theme={subject} tag={tag} conceptFilters={conceptFilters}
342+
setConceptFilters={setConceptFilters} tagCounts={tagCounts} incompatibleTags={[subjectTag]}
343+
/>)
344+
}
345+
</div>}
346+
</div>;
347+
})}
348+
</div>
349+
350+
<div className="section-divider"/>
351+
352+
{pageContext?.subject && <>
353+
<div className="section-divider"/>
354+
355+
<div className="sidebar-help">
356+
<p>The concepts shown on this page have been filtered to only show those that are relevant to {getHumanContext(pageContext)}.</p>
357+
<p>If you want to explore broader concepts across multiple subjects or learning stages, you can use the main concept browser:</p>
358+
<AffixButton size="md" color="keyline" tag={Link} to="/concepts" affix={{
359+
affix: "icon-right",
360+
position: "suffix",
361+
type: "icon"
362+
}}>
363+
Browse concepts
364+
</AffixButton>
365+
</div>
366+
</>}
367+
</ContentSidebar>;
278368
};
279369

280370
interface QuestionFinderSidebarProps extends SidebarProps {

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

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,13 @@ import React from "react";
22
import { AbstractListViewItem, AbstractListViewItemProps, ListViewTagProps } from "./AbstractListViewItem";
33
import { ShortcutResponse, ViewingContext } from "../../../../IsaacAppTypes";
44
import { determineAudienceViews } from "../../../services/userViewingContext";
5-
import { DOCUMENT_TYPE, documentTypePathPrefix, SEARCH_RESULT_TYPE, Subject, TAG_ID, TAG_LEVEL, tags } from "../../../services";
5+
import { DOCUMENT_TYPE, documentTypePathPrefix, getThemeFromContextAndTags, SEARCH_RESULT_TYPE, Subject, TAG_ID, TAG_LEVEL, tags } from "../../../services";
66
import { ListGroup, ListGroupItem, ListGroupItemProps, ListGroupProps } from "reactstrap";
77
import { TitleIconProps } from "../PageTitle";
88
import { AffixButton } from "../AffixButton";
99
import { QuizSummaryDTO } from "../../../../IsaacApiTypes";
1010
import { Link } from "react-router-dom";
11-
import { showQuizSettingModal, useAppDispatch } from "../../../state";
11+
import { selectors, showQuizSettingModal, useAppDispatch, useAppSelector } from "../../../state";
1212
import classNames from "classnames";
1313

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

4344
return <AbstractListViewItem
4445
{...rest}
4546
icon={{type: "hex", icon: "list-icon-question", size: "sm"}}
4647
title={item.title ?? ""}
47-
subject={itemSubject}
48+
subject={itemSubject !== "neutral" ? itemSubject : undefined}
4849
tags={item.tags}
4950
supersededBy={item.supersededBy}
5051
subtitle={item.subtitle}
@@ -56,13 +57,14 @@ export const QuestionListViewItem = (props : QuestionListViewItemProps) => {
5657
};
5758

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

6264
return <AbstractListViewItem
6365
icon={{type: "hex", icon: "list-icon-concept", size: "sm"}}
6466
title={item.title ?? ""}
65-
subject={itemSubject}
67+
subject={itemSubject !== "neutral" ? itemSubject : undefined}
6668
subtitle={item.subtitle}
6769
url={url}
6870
{...rest}

src/app/components/elements/panels/QuestionFinderFilterPanel.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -220,7 +220,7 @@ export function QuestionFinderFilterPanel(props: QuestionFinderFilterPanelProps)
220220
toggle={() => listStateDispatch({type: "toggle", id: "stage", focus: below["md"](deviceSize)})}
221221
numberSelected={(isAda && searchStages.includes(STAGE.ALL)) ? searchStages.length - 1 : searchStages.length}
222222
>
223-
{getFilteredStageOptions().filter(stage => pageStageToSearchStage(pageContext?.stage).includes(stage.value) || !pageContext?.stage).map((stage, index) => (
223+
{getFilteredStageOptions().filter(stage => pageStageToSearchStage(pageContext?.stage).includes(stage.value) || !pageContext?.stage?.length).map((stage, index) => (
224224
<div className={classNames("w-100 ps-3 py-1", {"bg-white": isAda, "ms-2": isPhy, "checkbox-region": isPhy && searchStages.includes(stage.value)})} key={index}>
225225
<StyledCheckbox
226226
color="primary"

0 commit comments

Comments
 (0)