diff --git a/src/IsaacAppTypes.tsx b/src/IsaacAppTypes.tsx index 421ebd3e9c..8b2e46c9a1 100644 --- a/src/IsaacAppTypes.tsx +++ b/src/IsaacAppTypes.tsx @@ -755,4 +755,5 @@ export type QuestionCorrectness = "CORRECT" | "INCORRECT" | "NOT_ANSWERED" | "NO export type PageContextState = { stage?: LearningStage[]; subject?: Subject; + previousContext?: Omit; } | null | undefined; diff --git a/src/app/components/elements/layout/SidebarLayout.tsx b/src/app/components/elements/layout/SidebarLayout.tsx index 0652572ee3..f699058795 100644 --- a/src/app/components/elements/layout/SidebarLayout.tsx +++ b/src/app/components/elements/layout/SidebarLayout.tsx @@ -3,13 +3,12 @@ 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"; @@ -17,6 +16,7 @@ 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) => { @@ -29,11 +29,12 @@ export const MainContent = (props: ColProps) => { return siteSpecific(, props.children); }; -const QuestionLink = (props: React.HTMLAttributes & {question: QuestionDTO, sidebarRef: RefObject}) => { - const { question, sidebarRef, ...rest } = props; +const QuestionLink = (props: React.HTMLAttributes & {question: QuestionDTO}) => { + const { question, ...rest } = props; + const subject = useAppSelector(selectors.pageContext.subject); const audienceFields = filterAudienceViewsByProperties(determineAudienceViews(question.audience), AUDIENCE_DISPLAY_FIELDS); - return
  • + return
  • @@ -44,10 +45,11 @@ const QuestionLink = (props: React.HTMLAttributes & {question: Qu
  • ; }; -const ConceptLink = (props: React.HTMLAttributes & {concept: IsaacConceptPageDTO, sidebarRef: RefObject}) => { - const { concept, sidebarRef, ...rest } = props; +const ConceptLink = (props: React.HTMLAttributes & {concept: IsaacConceptPageDTO}) => { + const { concept, ...rest } = props; + const subject = useAppSelector(selectors.pageContext.subject); - return
  • + return
  • {concept.title} @@ -141,7 +143,7 @@ export const QuestionSidebar = (props: QuestionSidebarProps) => {
    Related concepts
      - {relatedConcepts.map((concept, i) => )} + {relatedConcepts.map((concept, i) => )}
    } {relatedQuestions && relatedQuestions.length > 0 && <> @@ -150,19 +152,19 @@ export const QuestionSidebar = (props: QuestionSidebarProps) => {
    Related questions
      - {relatedQuestions.map((question, i) => )} + {relatedQuestions.map((question, i) => )}
    : <>
    Related {HUMAN_STAGES[pageContextStage[0]]} questions
      - {relatedQuestionsForContextStage.map((question, i) => )} + {relatedQuestionsForContextStage.map((question, i) => )}
    Related questions for other learning stages
      - {relatedQuestionsForOtherStages.map((question, i) => )} + {relatedQuestionsForOtherStages.map((question, i) => )}
    } @@ -186,36 +188,53 @@ export const ConceptSidebar = (props: QuestionSidebarProps) => { -interface FilterCheckboxProps extends React.HTMLAttributes { +interface FilterCheckboxProps extends React.HTMLAttributes { tag: Tag; conceptFilters: Tag[]; setConceptFilters: React.Dispatch>; tagCounts?: Record; + 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 ) => 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" + ? ) => handleCheckboxChange(e.target.checked)} + label={{tag.title} {tagCounts && isDefined(tagCounts[tag.id]) && ({tagCounts[tag.id]})}} + /> + : ) => handleCheckboxChange(e.target.checked)} + checkboxTitle={tag.title} count={tagCounts && isDefined(tagCounts[tag.id]) ? tagCounts[tag.id] : undefined} + />; }; const AllFiltersCheckbox = (props: Omit) => { - const { conceptFilters, setConceptFilters, tagCounts, ...rest } = props; - const [previousFilters, setPreviousFilters] = useState([]); + const { conceptFilters, setConceptFilters, tagCounts, baseTag, ...rest } = props; + const [previousFilters, setPreviousFilters] = useState(baseTag ? [baseTag] : []); return 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); } @@ -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
    Search concepts
    @@ -251,9 +272,19 @@ export const SubjectSpecificConceptListSidebar = (props: ConceptListSidebarProps
    Filter by topic
    - +
    - {applicableTags.map(tag => )} + {applicableTags.map(tag => + + )}
    @@ -272,9 +303,68 @@ export const SubjectSpecificConceptListSidebar = (props: ConceptListSidebarProps ; }; -export const GenericConceptsSidebar = (props: SidebarProps) => { - // TODO - return ; +export const GenericConceptsSidebar = (props: ConceptListSidebarProps) => { + const { searchText, setSearchText, conceptFilters, setConceptFilters, applicableTags, tagCounts, ...rest } = props; + + const pageContext = useAppSelector(selectors.pageContext.context); + + return +
    +
    Search concepts
    + ) => setSearchText(e.target.value)} + /> + +
    + +
    +
    Filter by subject
    + {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
    + + {isSelected &&
    + {descendentTags + .filter(tag => !isDefined(tagCounts) || tagCounts[tag.id] > 0) + // .sort((a, b) => tagCounts ? tagCounts[b.id] - tagCounts[a.id] : 0) + .map((tag, j) => ) + } +
    } +
    ; + })} +
    + +
    + + {pageContext?.subject && <> +
    + +
    +

    The concepts shown on this page have been filtered to only show those that are relevant to {getHumanContext(pageContext)}.

    +

    If you want to explore broader concepts across multiple subjects or learning stages, you can use the main concept browser:

    + + Browse concepts + +
    + } + ; }; interface QuestionFinderSidebarProps extends SidebarProps { diff --git a/src/app/components/elements/list-groups/ListView.tsx b/src/app/components/elements/list-groups/ListView.tsx index 67a4a4cfe0..45a5102b7d 100644 --- a/src/app/components/elements/list-groups/ListView.tsx +++ b/src/app/components/elements/list-groups/ListView.tsx @@ -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 { @@ -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 { }; 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 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) => (
    {dispatch(fetchDoc(DOCUMENT_TYPE.CONCEPT, conceptId));}, [conceptId]); const doc = useAppSelector((state: AppState) => state?.doc || null); const navigation = useNavigation(doc); const deviceSize = useDeviceSize(); - useEffect(() => { - if (doc && doc !== 404) { - const newPageContext = getUpdatedPageContext(prevPageContext, user && user.loggedIn && user.registeredContexts || undefined, doc); - dispatch(pageContextSlice.actions.updatePageContext(newPageContext)); - } - }, [dispatch, user, doc]); + const pageContext = usePreviousPageContext(user && user.loggedIn && user.registeredContexts || undefined, doc && doc !== 404 ? doc : undefined); const ManageButtons = () =>
    @@ -63,7 +57,7 @@ export const Concept = withRouter(({match: {params}, location: {search}, concept return { const doc = supertypedDoc as IsaacConceptPageDTO & DocumentSubject; return - + { const dispatch = useAppDispatch(); const user = useAppSelector(selectors.user.orNull); const concepts = useAppSelector((state: AppState) => state?.concepts?.results || null); - const pageContext = useUrlPageTheme({resetIfNotFound: true}); - - const subject = useAppSelector(selectors.pageContext.subject); + const pageContext = useUrlPageTheme(); const subjectToTagMap = { physics: TAG_ID.physics, @@ -30,10 +28,14 @@ export const Concepts = withRouter((props: RouteComponentProps) => { maths: TAG_ID.maths, }; - const applicableTags = tags.getDirectDescendents(subjectToTagMap[subject ?? "physics"]); + const applicableTags = pageContext?.subject ? tags.getDirectDescendents(subjectToTagMap[pageContext.subject]) : tags.allFieldTags; const tagCounts : Record = applicableTags.reduce((acc, t) => ({...acc, [t.id]: concepts?.filter(c => c.tags?.includes(t.id)).length || 0}), {}); - useEffect(() => {dispatch(fetchConcepts());}, [dispatch]); + useEffect(() => { + if (pageContext) { + dispatch(fetchConcepts(undefined, pageContext?.subject)); + } + }, [dispatch]); const searchParsed = queryString.parse(location.search); @@ -44,9 +46,10 @@ export const Concepts = withRouter((props: RouteComponentProps) => { const filters = (Array.isArray(filterParsed) ? filterParsed[0] || "" : filterParsed || "").split(","); const [searchText, setSearchText] = useState(query); - const [conceptFilters, setConceptFilters] = useState( - applicableTags.filter(f => filters.includes(f.id)) - ); + const [conceptFilters, setConceptFilters] = useState([ + ...(pageContext?.subject ? [tags.getById(subjectToTagMap[pageContext.subject])] : []), + ...applicableTags.filter(f => filters.includes(f.id)) + ]); const [shortcutResponse, setShortcutResponse] = useState(); @@ -54,7 +57,7 @@ export const Concepts = withRouter((props: RouteComponentProps) => { if (e) { e.preventDefault(); } - pushConceptsToHistory(history, searchText || "", conceptFilters.map(f => f.id)); + pushConceptsToHistory(history, searchText || "", [...conceptFilters.map(f => f.id)]); if (searchText) { setShortcutResponse(shortcuts(searchText)); @@ -87,6 +90,8 @@ export const Concepts = withRouter((props: RouteComponentProps) => { const crumb = isPhy && isFullyDefinedContext(pageContext) && generateSubjectLandingPageCrumbFromContext(pageContext); + const sidebarProps = {searchText, setSearchText, conceptFilters, setConceptFilters, applicableTags, tagCounts}; + return ( { icon={{type: "hex", icon: "page-icon-concept"}} /> - {pageContext?.subject ? : } + {pageContext?.subject + ? + : + } - + {isPhy &&
    + {shortcutAndFilteredSearchResults &&
    + Showing {shortcutAndFilteredSearchResults.length} results +
    } + + {shortcutAndFilteredSearchResults + ? + : No results found + } +
    + } + + {isAda &&

    - Search Results {query != "" ? shortcutAndFilteredSearchResults ? {shortcutAndFilteredSearchResults.length} : : null} + Search Results + {query !== "" + ? shortcutAndFilteredSearchResults + ? {shortcutAndFilteredSearchResults.length} + : + : null + }

    - + {shortcutAndFilteredSearchResults ? - isPhy ? - : - + : No results found} -
    +
    }
    diff --git a/src/app/components/pages/Question.tsx b/src/app/components/pages/Question.tsx index 08df4d43c1..00cc24826b 100644 --- a/src/app/components/pages/Question.tsx +++ b/src/app/components/pages/Question.tsx @@ -1,7 +1,7 @@ import React, {useEffect} from "react"; import {Button, Col, Container, Row} from "reactstrap"; import {match, RouteComponentProps, withRouter} from "react-router-dom"; -import {fetchDoc, goToSupersededByQuestion, pageContextSlice, selectors, useAppDispatch, useAppSelector} from "../../state"; +import {fetchDoc, goToSupersededByQuestion, selectors, useAppDispatch, useAppSelector} from "../../state"; import {ShowLoading} from "../handlers/ShowLoading"; import {IsaacQuestionPageDTO} from "../../../IsaacApiTypes"; import { @@ -9,7 +9,7 @@ import { DOCUMENT_TYPE, fastTrackProgressEnabledBoards, generateQuestionTitle, - getUpdatedPageContext, + usePreviousPageContext, isAda, isPhy, isStudent, @@ -77,12 +77,7 @@ export const Question = withRouter(({questionIdOverride, match, location, previe dispatch(fetchDoc(DOCUMENT_TYPE.QUESTION, questionId)); }, [dispatch, questionId]); - useEffect(() => { - if (doc && doc !== 404) { - const newPageContext = getUpdatedPageContext(prevPageContext, user && user.loggedIn && user.registeredContexts || undefined, doc); - dispatch(pageContextSlice.actions.updatePageContext(newPageContext)); - } - }, [dispatch, user, doc]); + const pageContext = usePreviousPageContext(user && user.loggedIn && user.registeredContexts || undefined, doc && doc !== 404 ? doc : undefined); return { const doc = supertypedDoc as IsaacQuestionPageDTO & DocumentSubject; @@ -90,7 +85,7 @@ export const Question = withRouter(({questionIdOverride, match, location, previe const isFastTrack = doc && doc.type === DOCUMENT_TYPE.FAST_TRACK_QUESTION; return - + { const user = useAppSelector((state: AppState) => state && state.user); const params = useQueryParams(false); const history = useHistory(); - const pageContext = useUrlPageTheme({resetIfNotFound: true}); + const pageContext = useUrlPageTheme(); const isSolitaryStage = pageStageToSearchStage(pageContext?.stage).length === 1; const [searchTopics, setSearchTopics] = useState(arrayFromPossibleCsv(params.topics)); diff --git a/src/app/services/pageContext.ts b/src/app/services/pageContext.ts index c104a3ba90..0ec656d258 100644 --- a/src/app/services/pageContext.ts +++ b/src/app/services/pageContext.ts @@ -4,8 +4,8 @@ import { LearningStage, LearningStages, PHY_NAV_SUBJECTS, SiteTheme, STAGE_TO_LE import { isDefined } from "./miscUtils"; import { useLocation } from "react-router"; import { HUMAN_STAGES, HUMAN_SUBJECTS } from "./constants"; -import { pageContextSlice, useAppDispatch } from "../state"; -import { useEffect } from "react"; +import { pageContextSlice, selectors, useAppDispatch, useAppSelector } from "../state"; +import { useEffect, useMemo } from "react"; const filterBySubjects = (tags: (TAG_ID | string)[]): SiteTheme[] => { // filtering this const list against the passed-in tags maintains the order (and thus precedence) of the subjects @@ -23,15 +23,14 @@ const filterBySubjects = (tags: (TAG_ID | string)[]): SiteTheme[] => { * * If no subject tags are found, `"neutral"` is returned as a default. * - * @param element - The element from which to find the active context theme. + * @param currentTheme - The current page theme. Find via e.g. `useAppSelector(selectors.pageContext.theme)`. * @param tags - The content object tags in which to search for a subject. * @returns The most relevant theme. */ -export const getThemeFromContextAndTags = (element: React.RefObject, tags: (TAG_ID | string)[]): SiteTheme => { - const currentTheme = element.current?.closest("[data-bs-theme]")?.getAttribute("data-bs-theme") as SiteTheme; +export const getThemeFromContextAndTags = (currentTheme: Subject | undefined, tags: (TAG_ID | string)[]): SiteTheme => { const subjectTags = filterBySubjects(tags); - if (currentTheme !== "neutral" && subjectTags.includes(currentTheme)) { + if (currentTheme && subjectTags.includes(currentTheme)) { return currentTheme; } @@ -65,8 +64,11 @@ export const getThemeFromTags = (tags?: (TAG_ID | string)[]): SiteTheme => { * @param doc - The current page DTO. The audience and tags of this object will be used to determine the new context. * @returns The page context for this page. */ -export const getUpdatedPageContext = (previousContext: PageContextState | undefined, userContexts: readonly UserContext[] | undefined, doc: ContentBaseDTO | undefined): PageContextState => { - const newContext = {stage: undefined, subject: undefined} as NonNullable; +export const usePreviousPageContext = (userContexts: readonly UserContext[] | undefined, doc: ContentBaseDTO | undefined): PageContextState => { + const previousContext = useAppSelector(selectors.pageContext.previousContext) as PageContextState; + const dispatch = useAppDispatch(); + + const newContext = useMemo(() => ({stage: undefined, subject: undefined, previousContext} as NonNullable), [previousContext]); // if we haven't changed learning stage (GCSE => GCSE), use the learning stage from the old context if (previousContext?.stage && doc?.audience?.some(a => a.stage?.map(s => STAGE_TO_LEARNING_STAGE[s]).filter(isDefined).some(s => previousContext.stage?.includes(s)))) { @@ -105,6 +107,18 @@ export const getUpdatedPageContext = (previousContext: PageContextState | undefi } // otherwise we cannot infer a subject to show, so the default of "neutral" is used + useEffect(() => { + dispatch(pageContextSlice.actions.updatePageContext(newContext)); + + return () => { + dispatch(pageContextSlice.actions.updatePageContext({ + subject: undefined, + stage: undefined, + previousContext: {subject: newContext.subject, stage: newContext.stage}, + })); + }; + }, [dispatch, doc]); + return newContext; }; @@ -127,38 +141,38 @@ function isValidIsaacStage(stage?: string): stage is LearningStage { function determinePageContextFromUrl(url: string): PageContextState { const [subject, stage] = url.split("/").filter(Boolean); - if (isValidIsaacSubject(subject) && stage === undefined) { - return {subject, stage: []}; - } - if (isValidIsaacSubject(subject) && isValidIsaacStage(stage)) { - return {subject, stage: [stage]}; - } - return {}; + + return { + subject: isValidIsaacSubject(subject) ? subject : undefined, + stage: isValidIsaacStage(stage) ? [stage] : [], + } as PageContextState; } /** * A hook for updating the page context based on the URL. Only use on pages where the URL is the source of truth for the page context. * (i.e. subject-specific pages, like question finders, concept pages, etc.) * If you want to get the current page context from redux rather than the URL, use `useAppSelector(selectors.pageContext.context)` instead. - * @param resetIfNotFound - If true, the page context will be reset if the URL does not contain a valid page context. This should be true on pages with a "neutral" version and a "subject-specific" version. * @returns The current page context. */ -export function useUrlPageTheme(params?: {resetIfNotFound?: boolean}): PageContextState { +export function useUrlPageTheme(): PageContextState { const location = useLocation(); const dispatch = useAppDispatch(); useEffect(() => { const urlContext = determinePageContextFromUrl(location.pathname); - if (urlContext?.subject || urlContext?.stage) { + dispatch(pageContextSlice.actions.updatePageContext({ + subject: urlContext?.subject, + stage: urlContext?.stage, + previousContext: {subject: urlContext?.subject, stage: urlContext?.stage}, + })); + + return () => { dispatch(pageContextSlice.actions.updatePageContext({ - subject: urlContext.subject, - stage: urlContext.stage + subject: undefined, + stage: undefined, + previousContext: {subject: urlContext?.subject, stage: urlContext?.stage}, })); - } else { - if (params?.resetIfNotFound) { - dispatch(pageContextSlice.actions.resetPageContext()); - } - } + }; }, [dispatch, location.pathname]); return determinePageContextFromUrl(location.pathname); diff --git a/src/app/state/selectors.tsx b/src/app/state/selectors.tsx index 2f97fef6c1..ff50644d15 100644 --- a/src/app/state/selectors.tsx +++ b/src/app/state/selectors.tsx @@ -77,6 +77,7 @@ export const selectors = { pageContext: { context: (state: AppState) => state?.pageContext ?? undefined, // transform null => undefined + previousContext: (state: AppState) => state?.pageContext?.previousContext ?? undefined, stage: (state: AppState) => state?.pageContext?.stage, subject: (state: AppState) => state?.pageContext?.subject, } diff --git a/src/app/state/slices/context.ts b/src/app/state/slices/context.ts index 45c9651c8f..a5c44f25bc 100644 --- a/src/app/state/slices/context.ts +++ b/src/app/state/slices/context.ts @@ -1,6 +1,5 @@ import { createSlice } from "@reduxjs/toolkit"; import { PageContextState } from "../../../IsaacAppTypes"; -import { Stage } from "../../../IsaacApiTypes"; interface actionType { payload: PageContextState; @@ -13,9 +12,15 @@ export const pageContextSlice = createSlice({ reducers: { updatePageContext: (state, action: actionType) => ({ ...state, + // stage and subject can be undefined, so should not ??-inherit from the previous context stage: action.payload?.stage, subject: action.payload?.subject, + previousContext: action.payload?.previousContext ?? state?.previousContext, + }), + resetPageContext: (state) => ({ + ...state, + stage: undefined, + subject: undefined, }), - resetPageContext: () => null, }, }); diff --git a/src/scss/phy/checkbox.scss b/src/scss/phy/checkbox.scss index 78e643ed3a..70e95eda23 100644 --- a/src/scss/phy/checkbox.scss +++ b/src/scss/phy/checkbox.scss @@ -17,6 +17,10 @@ .styled-checkbox-wrapper div input[type="checkbox"] { border: none; + + &[color="theme"] { + --checkbox-selected-color: var(--subject-color-500); + } } .icon-checkbox-off { @@ -73,7 +77,7 @@ } &::after { - background-color: $color-brand-500; + background-color: var(--checkbox-selected-color, $color-brand-500); } } @@ -85,7 +89,7 @@ ); &::before { - background-color: $color-brand-500; + background-color: var(--checkbox-selected-color, $color-brand-500); } &::after { background-color: $color-neutral-100; diff --git a/src/scss/phy/list-groups.scss b/src/scss/phy/list-groups.scss index ad0a372471..bfee7063d0 100644 --- a/src/scss/phy/list-groups.scss +++ b/src/scss/phy/list-groups.scss @@ -92,3 +92,7 @@ margin: 0.5rem 0.5rem 0 0; font-size: 14px; } + +.list-results-container { + background-color: $color-neutral-100; +} diff --git a/src/test/pages/__image_snapshots__/phy/Assignment schedule should have no visual regressions #0.png b/src/test/pages/__image_snapshots__/phy/Assignment schedule should have no visual regressions #0.png deleted file mode 100644 index ae6a8b30f7..0000000000 Binary files a/src/test/pages/__image_snapshots__/phy/Assignment schedule should have no visual regressions #0.png and /dev/null differ