Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
52 commits
Select commit Hold shift + click to select a range
9d68c8c
Migrate Question Finder to RTKQ
jacbn Nov 6, 2025
23c8838
Use RTKQ `isUninitialized` to prevent 1-frame error popup
jacbn Nov 6, 2025
8bc08ad
Migrate subject landing page random question to RTKQ
jacbn Nov 6, 2025
67ba917
Show nothing on uninitialised query, rather than loading
jacbn Nov 7, 2025
7534b8b
Migrate gameboards q search to RTKQ
jacbn Nov 7, 2025
9ca4f1b
Supersede action/reducer setup for question search
jacbn Nov 7, 2025
6f34b4f
Fix ESLint warnings
jacbn Nov 7, 2025
94a1c3a
Use leading edge in search debounces to make more responsive
jacbn Nov 7, 2025
4a49b90
Account for nullable `results` field in QF response
jacbn Nov 7, 2025
446ef92
Enable customisable uninit placeholder logic in query
jacbn Nov 10, 2025
09c48e4
Allow non-user QF on-load computation without rerender
jacbn Nov 10, 2025
683dbeb
Fix(?) QF jest tests
jacbn Nov 10, 2025
03c37b9
Remove unused vars
jacbn Nov 10, 2025
b2f2a98
Fix(??) more jest tests
jacbn Nov 10, 2025
a17366e
Include searching in waitForLoaded
jacbn Nov 10, 2025
3183272
tests: clickOn requires exactly one match
barna-isaac Nov 10, 2025
38247f5
silence windows.scrollTo errors
barna-isaac Nov 11, 2025
83307b1
fix ada QF tests, still expecting some failures
barna-isaac Nov 11, 2025
36a2550
Merge branch 'main' into improvement/question-search-rtkq-migration
barna-isaac Nov 11, 2025
4db743d
revert this! prove tests pass without these
barna-isaac Nov 12, 2025
c7502f0
stabilize "Load more" test
barna-isaac Nov 12, 2025
aad5dec
fix VRTs
barna-isaac Nov 12, 2025
aefbc15
Fix load more in context test
jacbn Nov 12, 2025
94c33c2
Tidy up context => STAGE function
jacbn Nov 12, 2025
e951720
Merge branch 'improvement/question-search-rtkq-migration' of https://…
jacbn Nov 12, 2025
08272a1
Pass only first param in context => stage conversion
jacbn Nov 12, 2025
6067557
Fix last tests, speed up Jest
jacbn Nov 12, 2025
1b99bdc
Remove unused enzyme bugfix
jacbn Nov 12, 2025
9edf179
Remove extra call to search when typing
jacbn Nov 12, 2025
5e4b75f
fix bug where sci qf kept random seed after typing
barna-isaac Nov 12, 2025
f4aa2c2
fix lint violation and prune suppressions
barna-isaac Nov 13, 2025
9ba0a69
tests: improve mock typing
barna-isaac Nov 13, 2025
c82b5d4
fix typescript errors introduced in prev commit
barna-isaac Nov 13, 2025
aee8da1
refactor: no need to use undefined as intermediary
barna-isaac Nov 13, 2025
26e6ad4
add test asserting disabled shuffle button
barna-isaac Nov 14, 2025
e0f2eb1
minor questionsApi fixes
barna-isaac Nov 14, 2025
599a953
clean up no longer used `questions.api`
barna-isaac Nov 14, 2025
356f807
fix bug where "Load more" would not disable
barna-isaac Nov 14, 2025
6a1e5a6
clean up questionsApi tests
barna-isaac Nov 14, 2025
954e924
finish questionsApi tests
barna-isaac Nov 14, 2025
e0e8f5c
add test for toast
bmagyarkuti Nov 15, 2025
4ac3257
use more precise types in test code
bmagyarkuti Nov 15, 2025
71b746a
consolidate setting default value for limit
bmagyarkuti Nov 15, 2025
bc26c38
fix `QuestionSearchModal` regression
barna-isaac Nov 17, 2025
5ae477e
inline `limit` function
barna-isaac Nov 18, 2025
2eb5bfc
Always show loading icon when searching for q
jacbn Nov 18, 2025
11d3746
Improve no-questions-found response
jacbn Nov 18, 2025
9c7537f
Mute icon colour alongside shuffle button text
jacbn Nov 18, 2025
cb21dcd
Merge branch 'improvement/question-search-rtkq-migration' of https://…
jacbn Nov 18, 2025
ed28c37
re-add parens around single param arrow function
barna-isaac Nov 18, 2025
81b24c1
undo change which ended up not being necessary
barna-isaac Nov 18, 2025
071eed0
Merge branch 'main' into improvement/question-search-rtkq-migration
barna-isaac Nov 18, 2025
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
5 changes: 4 additions & 1 deletion config/jest/jest.config.common.js
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,10 @@ module.exports = {
"transform": {
"^.+\\.css$": "<rootDir>config/jest/cssTransform.js",
"^(?!.*\\.(js|jsx|ts|tsx|css|json)$)": "<rootDir>config/jest/fileTransform.js",
'^.+\\.[jt]sx?$': "<rootDir>config/jest/tsTransform.js",
"^.+\\.[jt]sx?$": ["ts-jest", {
tsconfig: "<rootDir>/tsconfig.json",
}],

},
"transformIgnorePatterns": [
"/node_modules/(?!@popperjs|katex|leaflet)",
Expand Down
3 changes: 0 additions & 3 deletions config/jest/tsTransform.js

This file was deleted.

27 changes: 4 additions & 23 deletions eslint-suppressions.json
Original file line number Diff line number Diff line change
Expand Up @@ -350,9 +350,6 @@
}
},
"src/app/components/elements/layout/SidebarLayout.tsx": {
"@stylistic/indent": {
"count": 1
},
"@typescript-eslint/no-explicit-any": {
"count": 4
},
Expand Down Expand Up @@ -439,11 +436,8 @@
}
},
"src/app/components/elements/modals/QuestionSearchModal.tsx": {
"@typescript-eslint/no-floating-promises": {
"count": 2
},
"react-hooks/exhaustive-deps": {
"count": 3
"count": 2
}
},
"src/app/components/elements/modals/QuizSettingModal.tsx": {
Expand Down Expand Up @@ -731,7 +725,7 @@
"count": 4
},
"@typescript-eslint/no-unused-vars": {
"count": 3
"count": 2
},
"jsx-a11y/anchor-is-valid": {
"count": 1
Expand Down Expand Up @@ -869,7 +863,7 @@
"count": 2
},
"@typescript-eslint/no-floating-promises": {
"count": 10
"count": 9
},
"react-hooks/exhaustive-deps": {
"count": 2
Expand Down Expand Up @@ -924,7 +918,7 @@
},
"src/app/components/pages/QuestionFinder.tsx": {
"react-hooks/exhaustive-deps": {
"count": 2
"count": 1
}
},
"src/app/components/pages/QuickQuizzes.tsx": {
Expand Down Expand Up @@ -983,14 +977,6 @@
"count": 1
}
},
"src/app/components/pages/SubjectLandingPage.tsx": {
"@typescript-eslint/no-floating-promises": {
"count": 1
},
"react-hooks/exhaustive-deps": {
"count": 1
}
},
"src/app/components/pages/SubjectOverviewPage.tsx": {
"@typescript-eslint/no-unused-vars": {
"count": 2
Expand Down Expand Up @@ -1251,11 +1237,6 @@
"count": 1
}
},
"src/app/state/slices/api/baseApi.ts": {
"@typescript-eslint/no-unused-vars": {
"count": 1
}
},
"src/app/state/slices/api/contentApi.ts": {
"@typescript-eslint/no-unused-vars": {
"count": 2
Expand Down
4 changes: 0 additions & 4 deletions src/IsaacAppTypes.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -118,10 +118,6 @@ export type Action =
| {type: ACTION_TYPE.QUESTION_UNLOCK; questionId: string}
| {type: ACTION_TYPE.QUESTION_SET_CURRENT_ATTEMPT; questionId: string; attempt: Immutable<ApiTypes.ChoiceDTO | ValidatedChoice<ApiTypes.ChoiceDTO>>}

| {type: ACTION_TYPE.QUESTION_SEARCH_REQUEST}
| {type: ACTION_TYPE.QUESTION_SEARCH_RESPONSE_SUCCESS; questionResults: ApiTypes.SearchResultsWrapper<ApiTypes.ContentSummaryDTO>, searchId?: string}
| {type: ACTION_TYPE.QUESTION_SEARCH_RESPONSE_FAILURE}

| {type: ACTION_TYPE.MY_QUESTION_ANSWERS_BY_DATE_REQUEST}
| {type: ACTION_TYPE.MY_QUESTION_ANSWERS_BY_DATE_RESPONSE_SUCCESS; myAnsweredQuestionsByDate: ApiTypes.AnsweredQuestionsByDate}
| {type: ACTION_TYPE.MY_QUESTION_ANSWERS_BY_DATE_RESPONSE_FAILURE}
Expand Down
2 changes: 1 addition & 1 deletion src/app/components/elements/layout/SidebarLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -1084,7 +1084,7 @@ export const QuizSidebar = (props: QuizSidebarAttemptProps | QuizSidebarViewProp
<div className="section-divider"/>

<div className="d-flex flex-column sidebar-key">
Key
Key
<ul>
<KeyItem icon="not-started" text="Section not started"/>
<KeyItem icon="in-progress" text="Section in progress"/>
Expand Down
137 changes: 68 additions & 69 deletions src/app/components/elements/modals/QuestionSearchModal.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,10 @@
import React, {lazy, Suspense, useCallback, useEffect, useMemo, useReducer, useState} from "react";
import React, {lazy, useEffect, useMemo, useReducer, useState} from "react";
import {
AppState,
clearQuestionSearch,
closeActiveModal,
searchQuestions,
useAppDispatch,
useAppSelector
useAppSelector,
useSearchQuestionsQuery
} from "../../../state";
import debounce from "lodash/debounce";
import isEqual from "lodash/isEqual";
Expand Down Expand Up @@ -35,8 +34,8 @@ import {
useDeviceSize,
EXAM_BOARD, QUESTIONS_PER_GAMEBOARD
} from "../../../services";
import {ContentSummary, GameboardBuilderQuestions, GameboardBuilderQuestionsStackProps} from "../../../../IsaacAppTypes";
import {AudienceContext, Difficulty, ExamBoard} from "../../../../IsaacApiTypes";
import {ContentSummary, GameboardBuilderQuestions, GameboardBuilderQuestionsStackProps, QuestionSearchQuery} from "../../../../IsaacAppTypes";
import {AudienceContext, ContentSummaryDTO, Difficulty, ExamBoard} from "../../../../IsaacApiTypes";
import {GroupBase} from "react-select/dist/declarations/src/types";
import {Loading} from "../../handlers/IsaacSpinner";
import {StyledSelect} from "../inputs/StyledSelect";
Expand All @@ -49,6 +48,8 @@ import { CollapsibleList } from "../CollapsibleList";
import { StyledCheckbox } from "../inputs/StyledCheckbox";
import { updateTopicChoices, initialiseListState, listStateReducer } from "../../../services";
import { HorizontalScroller } from "../inputs/HorizontalScroller";
import { skipToken } from "@reduxjs/toolkit/query";
import { ShowLoadingQuery } from "../../handlers/ShowLoadingQuery";

// Immediately load GameboardBuilderRow, but allow splitting
const importGameboardBuilderRow = import("../GameboardBuilderRow");
Expand All @@ -72,6 +73,9 @@ export const QuestionSearchModal = (
const deviceSize = useDeviceSize();
const sublistDelimiter = " >>> ";

const [searchParams, setSearchParams] = useState<QuestionSearchQuery | typeof skipToken>(skipToken);
const searchQuestionsQuery = useSearchQuestionsQuery(searchParams);

const [topicSelections, setTopicSelections] = useState<ChoiceTree[]>([]);
const [searchTopics, setSearchTopics] = useState<string[]>([]);
const [searchQuestionName, setSearchQuestionName] = useState("");
Expand All @@ -83,7 +87,6 @@ export const QuestionSearchModal = (
if (userContext.contexts.length === 1 && !EXAM_BOARD_NULL_OPTIONS.includes(userExamBoard)) setSearchExamBoards([userExamBoard]);
}, [userContext.contexts[0].examBoard]);

const [isSearching, setIsSearching] = useState(false);
const [searchBook, setSearchBook] = useState<string[]>([]);
const isBookSearch = searchBook.length > 0;

Expand All @@ -101,30 +104,22 @@ export const QuestionSearchModal = (

const modalQuestions : GameboardBuilderQuestions = {selectedQuestions, questionOrder, setSelectedQuestions, setQuestionOrder};

const {results: questions} = useAppSelector((state: AppState) => state && state.questionSearchResult) || {};
const user = useAppSelector((state: AppState) => state && state.user);

useEffect(() => {
setIsSearching(false);
}, [questions]);

const searchDebounce = useCallback(
const searchDebounce = useMemo(() =>
debounce((searchString: string, topics: string[], examBoards: string[], book: string[], stages: string[], difficulties: string[], fasttrack: boolean, startIndex: number) => {
// Clear front-end sorting so as not to override ElasticSearch's match ranking
setQuestionsSort({});

const isBookSearch = book.length > 0; // Tasty.
if ([searchString, topics, book, stages, difficulties, examBoards].every(v => v.length === 0) && !fasttrack) {
// Nothing to search for
dispatch(clearQuestionSearch);
return;
return setSearchParams(skipToken);
}

const tags = (isBookSearch ? book : [...([topics].map((tags) => tags.join(" ")))].filter((query) => query != "")).join(",");

setIsSearching(true);

dispatch(searchQuestions({
setSearchParams({
querySource: "gameboardBuilder",
searchString: searchString || undefined,
tags: tags || undefined,
Expand All @@ -134,12 +129,11 @@ export const QuestionSearchModal = (
fasttrack,
startIndex,
limit: 300
}));
});

logEvent(eventLog,"SEARCH_QUESTIONS", {searchString, topics, examBoards, book, stages, difficulties, fasttrack, startIndex});
}, 250),
[]
);
}, 250, { leading: true }),
[eventLog]);

const sortableTableHeaderUpdateState = (sortState: Record<string, SortOrder>, setSortState: React.Dispatch<React.SetStateAction<Record<string, SortOrder>>>, key: string) => (order: SortOrder) => {
const newSortState = {...sortState};
Expand All @@ -155,19 +149,17 @@ export const QuestionSearchModal = (
searchDebounce(searchQuestionName, searchTopics, searchExamBoards, searchBook, searchStages, searchDifficulties, searchFastTrack, 0);
},[searchDebounce, searchQuestionName, searchTopics, searchExamBoards, searchBook, searchFastTrack, searchStages, searchDifficulties]);

const sortedQuestions = useMemo(() => {
return questions && sortQuestions(isBookSearch ? {title: SortOrder.ASC} : questionsSort, creationContext)(
questions.filter(question => {
const qIsPublic = searchResultIsPublic(question, user);
if (isBookSearch) return qIsPublic;
const qTopicsMatch =
searchTopics.length === 0 ||
(question.tags && question.tags.filter((tag) => searchTopics.includes(tag)).length > 0);
const sortAndFilterBySearch = (questions: ContentSummaryDTO[]) => questions && sortQuestions(isBookSearch ? {title: SortOrder.ASC} : questionsSort, creationContext)(
questions.filter(question => {
const qIsPublic = searchResultIsPublic(question, user);
if (isBookSearch) return qIsPublic;
const qTopicsMatch =
searchTopics.length === 0 ||
(question.tags && question.tags.filter((tag) => searchTopics.includes(tag)).length > 0);

return qIsPublic && qTopicsMatch;
})
);
}, [questions, user, searchTopics, isBookSearch, questionsSort, creationContext]);
return qIsPublic && qTopicsMatch;
})
);

const addSelectionsRow = <div className="d-sm-flex flex-xl-column align-items-center mt-2">
<div className="flex-grow-1 mb-1">
Expand Down Expand Up @@ -289,41 +281,48 @@ export const QuestionSearchModal = (
{addSelectionsRow}
</Col>
<Col className="col-12 col-xl-9">
<Suspense fallback={<Loading/>}>
<HorizontalScroller enabled={sortedQuestions && sortedQuestions.length > 6} className="my-4">
<Table bordered className="my-0">
<thead>
<tr className="search-modal-table-header">
<th className="w-5"> </th>
<SortItemHeader<SortOrder>
className={siteSpecific("w-40", "w-30")}
setOrder={sortableTableHeaderUpdateState(questionsSort, setQuestionsSort, "title")}
defaultOrder={SortOrder.ASC}
reverseOrder={SortOrder.DESC}
currentOrder={questionsSort['title']}
alignment="start"
>Question title</SortItemHeader>
<th className={siteSpecific("w-25", "w-20")}>Topic</th>
<th className="w-15">Stage</th>
<th className="w-15">Difficulty</th>
{isAda && <th className="w-15">Exam boards</th>}
</tr>
</thead>
<tbody>
{isSearching ? <tr><td colSpan={isAda ? 6 : 5}><Loading/></td></tr> : sortedQuestions?.map(question =>
<GameboardBuilderRow
key={`question-search-modal-row-${question.id}`}
question={question}
currentQuestions={modalQuestions}
undoStack={undoStack}
redoStack={redoStack}
creationContext={creationContext}
/>
)}
</tbody>
</Table>
</HorizontalScroller>
</Suspense>
<HorizontalScroller enabled className="my-4">
<Table bordered className="my-0">
<thead>
<tr className="search-modal-table-header">
<th className="w-5"> </th>
<SortItemHeader<SortOrder>
className={siteSpecific("w-40", "w-30")}
setOrder={sortableTableHeaderUpdateState(questionsSort, setQuestionsSort, "title")}
defaultOrder={SortOrder.ASC}
reverseOrder={SortOrder.DESC}
currentOrder={questionsSort['title']}
alignment="start"
>Question title</SortItemHeader>
<th className={siteSpecific("w-25", "w-20")}>Topic</th>
<th className="w-15">Stage</th>
<th className="w-15">Difficulty</th>
{isAda && <th className="w-15">Exam boards</th>}
</tr>
</thead>
<tbody>
<ShowLoadingQuery
query={searchQuestionsQuery}
placeholder={<tr><td colSpan={isAda ? 6 : 5}><Loading/></td></tr>}
defaultErrorTitle="Failed to load questions."
thenRender={({results: questions}) => {
if (!questions) return <></>;
const sortedQuestions = sortAndFilterBySearch(questions);
return sortedQuestions?.map(question =>
<GameboardBuilderRow
key={`question-search-modal-row-${question.id}`}
question={question}
currentQuestions={modalQuestions}
undoStack={undoStack}
redoStack={redoStack}
creationContext={creationContext}
/>
);
}}
/>
</tbody>
</Table>
</HorizontalScroller>
</Col>
</Row>;
};
11 changes: 9 additions & 2 deletions src/app/components/handlers/ShowLoadingQuery.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ interface ShowLoadingQueryInfo<T> {
data?: T | NOT_FOUND_TYPE;
isLoading: boolean;
isFetching: boolean;
isUninitialized: boolean;
isError: boolean;
error?: FetchBaseQueryError | SerializedError;
}
Expand All @@ -38,6 +39,7 @@ export function combineQueries<T, R, S>(firstQuery: ShowLoadingQueryInfo<T>, sec
data: isFound<T>(firstQuery.data) && isFound<R>(secondQuery.data) ? combineResult(firstQuery.data, secondQuery.data) : undefined,
isLoading: firstQuery.isLoading || secondQuery.isLoading,
isFetching: firstQuery.isFetching || secondQuery.isFetching,
isUninitialized: firstQuery.isUninitialized || secondQuery.isUninitialized,
isError: firstQuery.isError || secondQuery.isError,
error: firstQuery.error ?? secondQuery.error,
};
Expand All @@ -50,6 +52,7 @@ export const discardResults = (): true => true;

interface ShowLoadingQueryBaseProps<T> {
placeholder?: JSX.Element | JSX.Element[];
uninitializedPlaceholder?: JSX.Element | JSX.Element[];
query: ShowLoadingQueryInfo<T>;
ifNotFound?: JSX.Element | JSX.Element[];
maintainOnRefetch?: boolean;
Expand All @@ -74,8 +77,8 @@ type ShowLoadingQueryProps<T> = ShowLoadingQueryErrorProps<T> & ({
// - `placeholder` (React element to show while loading)
// - `maintainOnRefetch` (boolean indicating whether to keep showing the current data while refetching. use second parameter of `thenRender` to modify render tree accordingly)
// - `query` (the object returned by a RTKQ useQuery hook)
export function ShowLoadingQuery<T>({query, thenRender, children, placeholder, ifError, ifNotFound, defaultErrorTitle, maintainOnRefetch}: ShowLoadingQueryProps<T>) {
const {data, isLoading, isFetching, isError, error} = query;
export function ShowLoadingQuery<T>({query, thenRender, children, placeholder, uninitializedPlaceholder, ifError, ifNotFound, defaultErrorTitle, maintainOnRefetch}: ShowLoadingQueryProps<T>) {
const {data, isLoading, isFetching, isUninitialized, isError, error} = query;
const renderError = () => ifError ? <>{ifError(error)}</> : <DefaultQueryError error={error} title={defaultErrorTitle}/>;
if (isError && error) {
return "status" in error && typeof error.status === "number" && [NOT_FOUND, NO_CONTENT].includes(error.status) && ifNotFound ? <>{ifNotFound}</> : renderError();
Expand All @@ -84,6 +87,10 @@ export function ShowLoadingQuery<T>({query, thenRender, children, placeholder, i
const isStale = (isLoading || isFetching) && isFound<T>(data);
const showPlaceholder = (isLoading || isFetching) && (!maintainOnRefetch || !isDefined(data));

if (isUninitialized) {
return uninitializedPlaceholder ? <>{uninitializedPlaceholder}</> : null;
}

if (showPlaceholder) {
return placeholder ? <>{placeholder}</> : <LoadingPlaceholder />;
}
Expand Down
Loading
Loading