diff --git a/eslint-suppressions.json b/eslint-suppressions.json index 35c2c4d9f6..ec5aa6c666 100644 --- a/eslint-suppressions.json +++ b/eslint-suppressions.json @@ -439,11 +439,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": { @@ -869,7 +866,7 @@ "count": 2 }, "@typescript-eslint/no-floating-promises": { - "count": 10 + "count": 9 }, "react-hooks/exhaustive-deps": { "count": 2 @@ -983,14 +980,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 diff --git a/src/IsaacAppTypes.tsx b/src/IsaacAppTypes.tsx index 1b6aff9278..c6254af377 100644 --- a/src/IsaacAppTypes.tsx +++ b/src/IsaacAppTypes.tsx @@ -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>} - | {type: ACTION_TYPE.QUESTION_SEARCH_REQUEST} - | {type: ACTION_TYPE.QUESTION_SEARCH_RESPONSE_SUCCESS; questionResults: ApiTypes.SearchResultsWrapper, 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} diff --git a/src/app/components/elements/modals/QuestionSearchModal.tsx b/src/app/components/elements/modals/QuestionSearchModal.tsx index e42cd76b87..d9c19587ca 100644 --- a/src/app/components/elements/modals/QuestionSearchModal.tsx +++ b/src/app/components/elements/modals/QuestionSearchModal.tsx @@ -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"; @@ -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"; @@ -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"); @@ -72,6 +73,9 @@ export const QuestionSearchModal = ( const deviceSize = useDeviceSize(); const sublistDelimiter = " >>> "; + const [searchParams, setSearchParams] = useState(undefined); + const searchQuestionsQuery = useSearchQuestionsQuery(searchParams ?? skipToken); + const [topicSelections, setTopicSelections] = useState([]); const [searchTopics, setSearchTopics] = useState([]); const [searchQuestionName, setSearchQuestionName] = useState(""); @@ -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([]); const isBookSearch = searchBook.length > 0; @@ -101,14 +104,9 @@ 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({}); @@ -116,15 +114,12 @@ export const QuestionSearchModal = ( 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; } 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, @@ -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, setSortState: React.Dispatch>>, key: string) => (order: SortOrder) => { const newSortState = {...sortState}; @@ -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 =
@@ -289,41 +281,48 @@ export const QuestionSearchModal = ( {addSelectionsRow} - }> - 6} className="my-4"> - - - - - - className={siteSpecific("w-40", "w-30")} - setOrder={sortableTableHeaderUpdateState(questionsSort, setQuestionsSort, "title")} - defaultOrder={SortOrder.ASC} - reverseOrder={SortOrder.DESC} - currentOrder={questionsSort['title']} - alignment="start" - >Question title - - - - {isAda && } - - - - {isSearching ? : sortedQuestions?.map(question => - - )} - -
TopicStageDifficultyExam boards
-
-
+ + + + + + + className={siteSpecific("w-40", "w-30")} + setOrder={sortableTableHeaderUpdateState(questionsSort, setQuestionsSort, "title")} + defaultOrder={SortOrder.ASC} + reverseOrder={SortOrder.DESC} + currentOrder={questionsSort['title']} + alignment="start" + >Question title + + + + {isAda && } + + + + } + defaultErrorTitle="Failed to load questions." + thenRender={({results: questions}) => { + if (!questions) return <>; + const sortedQuestions = sortAndFilterBySearch(questions); + return sortedQuestions?.map(question => + + ); + }} + /> + +
TopicStageDifficultyExam boards
+
; }; diff --git a/src/app/components/handlers/ShowLoadingQuery.tsx b/src/app/components/handlers/ShowLoadingQuery.tsx index 1657ff2e09..fdfefe5c9e 100644 --- a/src/app/components/handlers/ShowLoadingQuery.tsx +++ b/src/app/components/handlers/ShowLoadingQuery.tsx @@ -29,6 +29,7 @@ interface ShowLoadingQueryInfo { data?: T | NOT_FOUND_TYPE; isLoading: boolean; isFetching: boolean; + isUninitialized: boolean; isError: boolean; error?: FetchBaseQueryError | SerializedError; } @@ -38,6 +39,7 @@ export function combineQueries(firstQuery: ShowLoadingQueryInfo, sec data: isFound(firstQuery.data) && isFound(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, }; @@ -75,7 +77,7 @@ type ShowLoadingQueryProps = ShowLoadingQueryErrorProps & ({ // - `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({query, thenRender, children, placeholder, ifError, ifNotFound, defaultErrorTitle, maintainOnRefetch}: ShowLoadingQueryProps) { - const {data, isLoading, isFetching, isError, error} = query; + const {data, isLoading, isFetching, isUninitialized, isError, error} = query; const renderError = () => ifError ? <>{ifError(error)} : ; if (isError && error) { return "status" in error && typeof error.status === "number" && [NOT_FOUND, NO_CONTENT].includes(error.status) && ifNotFound ? <>{ifNotFound} : renderError(); @@ -84,6 +86,10 @@ export function ShowLoadingQuery({query, thenRender, children, placeholder, i const isStale = (isLoading || isFetching) && isFound(data); const showPlaceholder = (isLoading || isFetching) && (!maintainOnRefetch || !isDefined(data)); + if (isUninitialized) { + return null; + } + if (showPlaceholder) { return placeholder ? <>{placeholder} : ; } diff --git a/src/app/components/pages/QuestionFinder.tsx b/src/app/components/pages/QuestionFinder.tsx index d4020c62c7..0f8601e8a1 100644 --- a/src/app/components/pages/QuestionFinder.tsx +++ b/src/app/components/pages/QuestionFinder.tsx @@ -1,5 +1,5 @@ import React, {useCallback, useEffect, useMemo, useState} from "react"; -import {AppState, clearQuestionSearch, searchQuestions, useAppDispatch, useAppSelector} from "../../state"; +import {AppState, useAppSelector, useSearchQuestionsQuery} from "../../state"; import debounce from "lodash/debounce"; import { arrayFromPossibleCsv, @@ -34,10 +34,9 @@ import { useQueryParams, useUrlPageTheme, } from "../../services"; -import {ContentSummaryDTO, Difficulty, ExamBoard} from "../../../IsaacApiTypes"; +import {Difficulty, ExamBoard} from "../../../IsaacApiTypes"; import {IsaacSpinner} from "../handlers/IsaacSpinner"; import {useHistory, withRouter} from "react-router"; -import {ShowLoading} from "../handlers/ShowLoading"; import {generateSubjectLandingPageCrumbFromContext, TitleAndBreadcrumb} from "../elements/TitleAndBreadcrumb"; import {MetaDescription} from "../elements/MetaDescription"; import {CanonicalHrefElement} from "../navigation/CanonicalHrefElement"; @@ -57,6 +56,9 @@ import { Link } from "react-router-dom"; import { updateTopicChoices } from "../../services"; import { PageMetadata } from "../elements/PageMetadata"; import { ResultsListContainer, ResultsListHeader } from "../elements/ListResultsContainer"; +import { QuestionSearchQuery } from "../../../IsaacAppTypes"; +import { skipToken } from "@reduxjs/toolkit/query"; +import { ShowLoadingQuery } from "../handlers/ShowLoadingQuery"; // Type is used to ensure that we check all query params if a new one is added in the future const FILTER_PARAMS = ["query", "topics", "fields", "subjects", "stages", "difficulties", "examBoards", "book", "excludeBooks", "statuses", "randomSeed"] as const; @@ -146,8 +148,14 @@ export const FilterSummary = ({filterTags, clearFilters, removeFilterTag}: Filte
; }; +const loadingPlaceholder = +
+ + +
+
; + export const QuestionFinder = withRouter(() => { - const dispatch = useAppDispatch(); const user = useAppSelector((state: AppState) => state && state.user); const params = useQueryParams(false); const history = useHistory(); @@ -201,8 +209,6 @@ export const QuestionFinder = withRouter(() => { } }, [pageContext]); - const [disableLoadMore, setDisableLoadMore] = useState(false); - const choices = useMemo(() => { return updateTopicChoices(selections, pageContext, getAllowedTags(pageContext)); }, [selections, pageContext]); @@ -214,7 +220,8 @@ export const QuestionFinder = withRouter(() => { // this should only update when a new search is triggered, not (necessarily) when the filters change const [isCurrentSearchEmpty, setIsCurrentSearchEmpty] = useState(isEmptySearch(searchQuery, searchTopics, searchBooks, searchStages, searchDifficulties, searchExamBoards, selections)); - const {results: questions, totalResults: totalQuestions, nextSearchOffset} = useAppSelector((state: AppState) => state && state.questionSearchResult) || {}; + const [searchParams, setSearchParams] = useState(undefined); + const searchQuestionsQuery = useSearchQuestionsQuery(searchParams ?? skipToken); const debouncedSearch = useMemo(() => debounce(({ @@ -231,7 +238,6 @@ export const QuestionFinder = withRouter(() => { }): void => { if (isEmptySearch(searchString, topics, book, stages, difficulties, examBoards, hierarchySelections)) { setIsCurrentSearchEmpty(true); - return void dispatch(clearQuestionSearch); } const choiceTreeLeaves = getChoiceTreeLeaves(hierarchySelections).map(leaf => leaf.value); @@ -250,7 +256,7 @@ export const QuestionFinder = withRouter(() => { setIsCurrentSearchEmpty(false); - void dispatch(searchQuestions({ + setSearchParams({ querySource: "questionFinder", searchString: searchString || undefined, tags: choiceTreeLeaves.join(",") || undefined, @@ -265,19 +271,17 @@ export const QuestionFinder = withRouter(() => { statuses: questionStatusToURIComponent(questionStatuses), fasttrack: false, startIndex, - limit: SEARCH_RESULTS_PER_PAGE + 1, // request one more than we need to know if there are more results + limit: SEARCH_RESULTS_PER_PAGE, randomSeed - })); - }, 250), - [dispatch, pageContext]); + }); + }, 250, { leading: true }), + [pageContext]); const filteringByStatus = Object.values(searchStatuses).some(v => v) && !Object.values(searchStatuses).every(v => v); const searchAndUpdateURL = useCallback(() => { setPageCount(1); - setDisableLoadMore(false); - setDisplayQuestions(undefined); const filteredStages = !searchStages.length && pageContext?.stage ? pageStageToSearchStage(pageContext.stage) : searchStages; debouncedSearch({ @@ -340,32 +344,10 @@ export const QuestionFinder = withRouter(() => { } }, [searchStages]); - const questionList = useMemo(() => { - if (questions) { - if (questions.length < SEARCH_RESULTS_PER_PAGE + 1) { - setDisableLoadMore(true); - } else { - setDisableLoadMore(false); - } - - return questions.slice(0, SEARCH_RESULTS_PER_PAGE); - } - }, [questions]); - - const [displayQuestions, setDisplayQuestions] = useState([]); const [pageCount, setPageCount] = useState(1); const [validFiltersSelected, setValidFiltersSelected] = useState(false); - useEffect(() => { - if (displayQuestions && nextSearchOffset && pageCount > 1) { - setDisplayQuestions(dqs => [...dqs ?? [], ...questionList ?? []]); - } else { - setDisplayQuestions(questionList); - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [questionList]); - useEffect(function onFiltersChanged() { setSearchDisabled(false); setValidFiltersSelected(searchDifficulties.length > 0 @@ -424,11 +406,6 @@ export const QuestionFinder = withRouter(() => { "Search for the perfect computer science questions to study. For revision. For homework. For the classroom." ); - const loadingPlaceholder =
- - -
; - function removeFilterTag(filter: string) { if (searchStages.includes(filter as STAGE)) { setSearchStages(searchStages.filter(f => f !== filter)); @@ -551,78 +528,86 @@ export const QuestionFinder = withRouter(() => { }} /> } - - -
- {displayQuestions && displayQuestions.length > 0 - ? <>Showing {displayQuestions.length} - : isPhy && isCurrentSearchEmpty - ? <>Select {filteringByStatus ? "more" : "some"} filters to start searching - : <>No results - } - {(totalQuestions ?? 0) > 0 && !filteringByStatus && <> of {totalQuestions}} - . -
- -
- - - {displayQuestions?.length - ? isPhy - ? - : - : isAda && <>{ - isCurrentSearchEmpty - ? Please select and apply filters. - : filteringByStatus - ? Could not load any results matching the requested filters. - : No results match the requested filters. - } + 1} + thenRender={({ results: questions, totalResults: totalQuestions, nextSearchOffset, moreResultsAvailable }, isStale) => { + return <> + + +
+ <>{questions && questions.length > 0 + ? <> + Showing {questions.length} + {(totalQuestions ?? 0) > 0 && !filteringByStatus && <> of {totalQuestions}} + . + + : isPhy && isCurrentSearchEmpty + ? <>Select {filteringByStatus ? "more" : "some"} filters to start searching. + : <>No results. + } +
+ +
+ + <>{questions?.length + ? isPhy + ? + : + : isAda && <>{ + isCurrentSearchEmpty + ? Please select and apply filters. + : filteringByStatus + ? Could not load any results matching the requested filters. + : No results match the requested filters. + } + } + +
+ {(questions?.length ?? 0) > 0 && + + + + + } -
-
-
- {(displayQuestions?.length ?? 0) > 0 && - - - - - } + ; + }} + /> diff --git a/src/app/components/pages/SubjectLandingPage.tsx b/src/app/components/pages/SubjectLandingPage.tsx index 6d3751824c..536a1a86b3 100644 --- a/src/app/components/pages/SubjectLandingPage.tsx +++ b/src/app/components/pages/SubjectLandingPage.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useEffect, useState } from "react"; +import React, { useEffect, useMemo, useState } from "react"; import { RouteComponentProps, withRouter } from "react-router"; import { Button, Col, Container, Row } from "reactstrap"; import { TitleAndBreadcrumb } from "../elements/TitleAndBreadcrumb"; @@ -6,10 +6,10 @@ import { getHumanContext, isFullyDefinedContext, isSingleStageContext, useUrlPag import { ListView, ListViewCards } from "../elements/list-groups/ListView"; import { getBooksForContext, getLandingPageCardsForContext } from "./subjectLandingPageComponents"; import { below, BookInfo, DOCUMENT_TYPE, EventStatusFilter, EventTypeFilter, isStudent, nextSeed, STAGE, STAGE_TO_LEARNING_STAGE, useDeviceSize } from "../../services"; -import { AugmentedEvent, PageContextState } from "../../../IsaacAppTypes"; +import { AugmentedEvent, PageContextState, QuestionSearchQuery } from "../../../IsaacAppTypes"; import { Link } from "react-router-dom"; import { ShowLoadingQuery } from "../handlers/ShowLoadingQuery"; -import { searchQuestions, selectors, useAppDispatch, useAppSelector, useGetNewsPodListQuery, useLazyGetEventsQuery } from "../../state"; +import { selectors, useAppSelector, useGetNewsPodListQuery, useLazyGetEventsQuery, useSearchQuestionsQuery } from "../../state"; import { EventCard } from "../elements/cards/EventCard"; import debounce from "lodash/debounce"; import { IsaacSpinner } from "../handlers/IsaacSpinner"; @@ -18,23 +18,28 @@ import { NewsCard } from "../elements/cards/NewsCard"; import { BookCard } from "./BooksOverview"; import { placeholderIcon } from "../elements/PageTitle"; import { ContentSummaryDTO, IsaacPodDTO } from "../../../IsaacApiTypes"; -import {v4 as uuid_v4} from "uuid"; +import { skipToken } from "@reduxjs/toolkit/query"; + +const loadingPlaceholder =
    +
  • + +
  • +
; const RandomQuestionBanner = ({context}: {context?: PageContextState}) => { - const dispatch = useAppDispatch(); - const [randomSeed, setrandomSeed] = useState(nextSeed); + const [randomSeed, setRandomSeed] = useState(nextSeed); + + const handleGetDifferentQuestion = () => setRandomSeed(nextSeed); - const handleGetDifferentQuestion = () => setrandomSeed(nextSeed); - const [searchId, setSearchId] = useState(''); + const [searchParams, setSearchParams] = useState(undefined); + const searchQuestionsQuery = useSearchQuestionsQuery(searchParams ?? skipToken); - const searchDebounce = useCallback(debounce(() => { + const searchDebounce = useMemo(() => debounce(() => { if (!isFullyDefinedContext(context)) { return; } - const nextSearchId = uuid_v4(); - setSearchId(nextSearchId); - dispatch(searchQuestions({ + setSearchParams({ querySource: "randomQuestion", searchString: "", tags: "", @@ -51,17 +56,13 @@ const RandomQuestionBanner = ({context}: {context?: PageContextState}) => { startIndex: undefined, limit: 1, randomSeed - }, nextSearchId)); - }), [dispatch, context, randomSeed]); - - const {results: questions, searchId: questionSearchId } = useAppSelector((state) => state && state.questionSearchResult) || {}; + }); + }, 250, { leading: true }), [context, randomSeed]); useEffect(() => { searchDebounce(); }, [searchDebounce]); - const question = questions?.[0]; - return

Try a random question!

@@ -70,20 +71,27 @@ const RandomQuestionBanner = ({context}: {context?: PageContextState}) => {
- {question && searchId === questionSearchId - ? - :
    -
  • - -
  • -
- } + { + const question = questions?.[0]; + return question + ? + :
    +
  • + +
  • +
; + }} + />
; }; diff --git a/src/app/services/constants.ts b/src/app/services/constants.ts index 97ed74c66b..9f9477c048 100644 --- a/src/app/services/constants.ts +++ b/src/app/services/constants.ts @@ -190,10 +190,6 @@ export enum ACTION_TYPE { QUESTION_UNLOCK = "QUESTION_UNLOCK", QUESTION_SET_CURRENT_ATTEMPT = "QUESTION_SET_CURRENT_ATTEMPT", - QUESTION_SEARCH_REQUEST = "QUESTION_SEARCH_REQUEST", - QUESTION_SEARCH_RESPONSE_SUCCESS = "QUESTION_SEARCH_RESPONSE_SUCCESS", - QUESTION_SEARCH_RESPONSE_FAILURE = "QUESTION_SEARCH_RESPONSE_FAILURE", - MY_QUESTION_ANSWERS_BY_DATE_REQUEST = "MY_QUESTION_ANSWERS_BY_DATE_REQUEST", MY_QUESTION_ANSWERS_BY_DATE_RESPONSE_SUCCESS = "MY_QUESTION_ANSWERS_BY_DATE_RESPONSE_SUCCESS", MY_QUESTION_ANSWERS_BY_DATE_RESPONSE_FAILURE = "MY_QUESTION_ANSWERS_BY_DATE_RESPONSE_FAILURE", diff --git a/src/app/state/actions/index.tsx b/src/app/state/actions/index.tsx index d8ed976436..7274567804 100644 --- a/src/app/state/actions/index.tsx +++ b/src/app/state/actions/index.tsx @@ -19,7 +19,6 @@ import { CredentialsAuthDTO, FreeTextRule, InlineContext, - QuestionSearchQuery, UserSnapshot, ValidatedChoice, } from "../../../IsaacAppTypes"; @@ -481,29 +480,6 @@ export function setCurrentAttempt(questionId: string, attem }); } -let questionSearchCounter = 0; - -export const searchQuestions = (query: QuestionSearchQuery, searchId?: string) => async (dispatch: Dispatch) => { - const searchCount = ++questionSearchCounter; - dispatch({type: ACTION_TYPE.QUESTION_SEARCH_REQUEST}); - try { - const questionsResponse = await api.questions.search(query); - // Because some searches might take longer to return that others, check this is the most recent search still. - // Otherwise, we just discard the data. - if (searchCount === questionSearchCounter) { - dispatch({type: ACTION_TYPE.QUESTION_SEARCH_RESPONSE_SUCCESS, questionResults: questionsResponse.data, searchId}); - } - } catch (e) { - dispatch({type: ACTION_TYPE.QUESTION_SEARCH_RESPONSE_FAILURE}); - dispatch(showAxiosErrorToastIfNeeded("Failed to search for questions", e)); - } -}; - -export const clearQuestionSearch = async (dispatch: Dispatch) => { - questionSearchCounter++; - dispatch({type: ACTION_TYPE.QUESTION_SEARCH_RESPONSE_SUCCESS, questionResults: {results: [], totalResults: 0}}); -}; - export const getMyAnsweredQuestionsByDate = (userId: number | string, fromDate: number, toDate: number, perDay: boolean) => async (dispatch: Dispatch) => { dispatch({type: ACTION_TYPE.MY_QUESTION_ANSWERS_BY_DATE_REQUEST}); try { diff --git a/src/app/state/index.ts b/src/app/state/index.ts index 9936e9e923..5e0330c4ba 100644 --- a/src/app/state/index.ts +++ b/src/app/state/index.ts @@ -23,7 +23,6 @@ export * from "./reducers/quizState"; export * from "./reducers/questionState"; export * from "./reducers/notifiersState"; export * from "./reducers/groupsState"; -export * from "./reducers/gameboardsState"; export * from "./reducers/progressState"; export * from "./reducers/adminState"; diff --git a/src/app/state/reducers/gameboardsState.ts b/src/app/state/reducers/gameboardsState.ts deleted file mode 100644 index 457c0e96f9..0000000000 --- a/src/app/state/reducers/gameboardsState.ts +++ /dev/null @@ -1,18 +0,0 @@ -import {ContentSummaryDTO, SearchResultsWrapper} from "../../../IsaacApiTypes"; -import {Action} from "../../../IsaacAppTypes"; -import {ACTION_TYPE} from "../../services"; - -type QuestionSearchResultState = SearchResultsWrapper & {searchId?: string} | null; -export const questionSearchResult = (questionSearchResult: QuestionSearchResultState = null, action: Action) => { - switch(action.type) { - case ACTION_TYPE.QUESTION_SEARCH_RESPONSE_SUCCESS: { - return {...action.questionResults, searchId: action.searchId}; - } - case ACTION_TYPE.QUESTION_SEARCH_RESPONSE_FAILURE: { - return null; - } - default: { - return questionSearchResult; - } - } -}; diff --git a/src/app/state/reducers/index.ts b/src/app/state/reducers/index.ts index 6a32e87998..4282e856f6 100644 --- a/src/app/state/reducers/index.ts +++ b/src/app/state/reducers/index.ts @@ -20,7 +20,6 @@ import { testQuestions, quizAttempt, groupMemberships, - questionSearchResult, search, isaacApi, gameboardsSlice, @@ -82,7 +81,6 @@ export const rootReducer = combineReducers({ // Gameboards boards: gameboardsSlice.reducer, - questionSearchResult, // Search search, diff --git a/src/app/state/slices/api/questionsApi.ts b/src/app/state/slices/api/questionsApi.ts index 7fb3cc824d..9260a5b0c0 100644 --- a/src/app/state/slices/api/questionsApi.ts +++ b/src/app/state/slices/api/questionsApi.ts @@ -1,13 +1,61 @@ -import { IsaacQuestionPageDTO } from "../../../../IsaacApiTypes"; -import { CanAttemptQuestionTypeDTO } from "../../../../IsaacAppTypes"; -import { tags } from "../../../services"; +import { ContentSummaryDTO, IsaacQuestionPageDTO } from "../../../../IsaacApiTypes"; +import { CanAttemptQuestionTypeDTO, QuestionSearchQuery } from "../../../../IsaacAppTypes"; +import { isDefined, SEARCH_RESULTS_PER_PAGE, tags } from "../../../services"; import { docSlice } from "../doc"; import { isaacApi } from "./baseApi"; import { onQueryLifecycleEvents } from "./utils"; +interface QuestionSearchResponseType { + results?: ContentSummaryDTO[]; + totalResults?: number; + nextSearchOffset?: number; + moreResultsAvailable?: boolean; // frontend only; calculated in transformResponse +} export const questionsApi = isaacApi.enhanceEndpoints({addTagTypes: ["CanAttemptQuestionType"]}).injectEndpoints({ endpoints: (build) => ({ + searchQuestions: build.query({ + query: (query: QuestionSearchQuery) => ({ + url: `/pages/questions`, + params: { + ...query, + limit: query.limit ? query.limit + 1 : SEARCH_RESULTS_PER_PAGE + 1 // fetch one extra to check if more results are available + } + }), + serializeQueryArgs: (args) => { + const { queryArgs, ...rest } = args; + const { startIndex: _startIndex, ...otherParams } = queryArgs; + // So that different queries with different pagination params still share the same cache + return { + ...rest, + queryArgs: otherParams + }; + }, + transformResponse: (response: QuestionSearchResponseType, _, arg) => { + return { + ...response, + // remove the extra result used to check for more results, so that we return the correct amount + moreResultsAvailable: isDefined(response.results) ? response.results.length > (arg.limit ?? SEARCH_RESULTS_PER_PAGE) : undefined, + results: response.results?.slice(0, (arg.limit ?? SEARCH_RESULTS_PER_PAGE)) + }; + }, + merge: (currentCache, newItems) => { + if (currentCache.results) { + currentCache.results.push(...(newItems.results ?? [])); + } else { + currentCache.results = newItems.results; + } + currentCache.totalResults = newItems.totalResults; + currentCache.nextSearchOffset = newItems.nextSearchOffset; + }, + forceRefetch({ currentArg, previousArg }) { + return currentArg !== previousArg; + }, + onQueryStarted: onQueryLifecycleEvents({ + errorTitle: "Unable to search for questions", + }), + }), + getQuestion: build.query({ query: (id) => ({ url: `/pages/questions/${id}` @@ -33,6 +81,8 @@ export const questionsApi = isaacApi.enhanceEndpoints({addTagTypes: ["CanAttempt }); export const { + useSearchQuestionsQuery, + useLazySearchQuestionsQuery, useGetQuestionQuery, useCanAttemptQuestionTypeQuery, } = questionsApi;