Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 0 additions & 3 deletions src/IsaacAppTypes.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -142,9 +142,6 @@ export type Action =
| {type: ACTION_TYPE.TOPIC_RESPONSE_SUCCESS; topic: ApiTypes.IsaacTopicSummaryPageDTO}
| {type: ACTION_TYPE.TOPIC_RESPONSE_FAILURE}

| {type: ACTION_TYPE.SEARCH_REQUEST; query: string; types: string | undefined}
| {type: ACTION_TYPE.SEARCH_RESPONSE_SUCCESS; searchResults: ApiTypes.ResultsWrapper<ApiTypes.ContentSummaryDTO>}

| {type: ACTION_TYPE.TOASTS_SHOW; toast: Toast}
| {type: ACTION_TYPE.TOASTS_HIDE; toastId: string}
| {type: ACTION_TYPE.TOASTS_REMOVE; toastId: string}
Expand Down
34 changes: 20 additions & 14 deletions src/app/components/elements/SearchInputs.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
import React, {ChangeEvent, FormEvent, useEffect, useRef, useState} from "react";
import React, {ChangeEvent, useCallback, useEffect, useRef, useState} from "react";
import {Button, Form, Input, InputGroup, InputProps, Label} from "reactstrap";
import {ifKeyIsEnter, pushSearchToHistory, SEARCH_CHAR_LENGTH_LIMIT, siteSpecific} from "../../services";
import {ifKeyIsEnter, SEARCH_CHAR_LENGTH_LIMIT, siteSpecific} from "../../services";
import classNames from "classnames";
import { useHistory, useLocation } from "react-router";

interface SearchInputProps {
setSearchText: (s: string) => void;
Expand All @@ -23,22 +22,29 @@ function withSearch(Component: React.FC<SearchInputProps>) {
const [searchText, setSearchText] = useState(initialValue ?? "");
const searchInputRef = useRef<HTMLInputElement>(null);

const history = useHistory();
function doSearch(e: FormEvent<HTMLFormElement>) {
e.preventDefault();
if (searchText === "") {
const doSearch = useCallback((text: string) => {
if (text === "") {
if (searchInputRef.current) searchInputRef.current.focus();
} else {
onSearch?.(searchText);
pushSearchToHistory(history, searchText, []);
onSearch?.(text);
}
}
}, [onSearch]);

// Clear this search field on location (i.e. search query) change - user should use the main search bar
const location = useLocation();
useEffect(() => { if (location.pathname === "/search") { setSearchText(initialValue ?? ""); }}, [location]);
useEffect(() => {
// If the initial value changes, update the search text - allows the search input to reflect URL changes
if (initialValue !== undefined) {
setSearchText(initialValue);
}
}, [initialValue]);

return <Form onSubmit={doSearch} className={classNames(className, {"form-inline" : inline})}>
return <Form
className={classNames(className, {"form-inline" : inline})}
data-testid="search-form"
onSubmit={(e) => {
e.preventDefault();
doSearch(searchText);
}}
>
<div className='form-group search--main-group'>
<Component inputProps={{
maxLength: SEARCH_CHAR_LENGTH_LIMIT,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -258,6 +258,7 @@ export const AbstractListViewItem = ({title, icon, subject, subtitle, breadcrumb
return <ListGroupItem
className={classNames("content-summary-item", {"correct": isItem && typedProps.status === CompletionState.ALL_CORRECT}, className, state)}
data-bs-theme={subject && !isDisabled ? subject : "neutral"}
data-testid={"list-view-item"}
>
{cardBody}
</ListGroupItem>;
Expand Down
96 changes: 48 additions & 48 deletions src/app/components/pages/Search.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import React, {FormEvent, MutableRefObject, useEffect, useRef, useState} from "react";
import React, {useEffect, useMemo, useState} from "react";
import {RouteComponentProps, withRouter} from "react-router-dom";
import {AppState, fetchSearch, selectors, useAppDispatch, useAppSelector} from "../../state";
import {selectors, useAppSelector, useSearchRequestQuery} from "../../state";
import {
Card,
CardBody,
Expand All @@ -10,11 +10,11 @@ import {
Form,
Container,
} from "reactstrap";
import {ShowLoading} from "../handlers/ShowLoading";
import {
DOCUMENT_TYPE,
documentDescription,
isAda,
isDefined,
parseLocationSearch,
pushSearchToHistory,
SEARCH_RESULT_TYPE,
Expand All @@ -28,11 +28,15 @@ import {TitleAndBreadcrumb} from "../elements/TitleAndBreadcrumb";
import {ShortcutResponse} from "../../../IsaacAppTypes";
import {UserContextPicker} from "../elements/inputs/UserContextPicker";
import {CSSObjectWithLabel, GroupBase, StylesConfig} from "react-select";
import {IsaacSpinner} from "../handlers/IsaacSpinner";
import classNames from "classnames";
import {SearchPageSearch} from "../elements/SearchInputs";
import {StyledSelect} from "../elements/inputs/StyledSelect";
import { ListView } from "../elements/list-groups/ListView";
import { ContentSummaryDTO } from "../../../IsaacApiTypes";
import { ShowLoadingQuery } from "../handlers/ShowLoadingQuery";
import { History } from "history";
import { debounce } from "lodash";
import { skipToken } from "@reduxjs/toolkit/query";

interface Item<T> {
value: T;
Expand All @@ -57,70 +61,61 @@ const selectStyle: StylesConfig<Item<SearchableDocumentType>, true, GroupBase<It
menuPortal: base => ({ ...base, zIndex: 19 })
};

function updateSearchUrl(history: History<unknown>, queryState: Nullable<string>, filtersState: Item<SearchableDocumentType>[]) {
pushSearchToHistory(history, queryState || "", filtersState.map(deitemise));
}

// Interacting with the page's filters change the query parameters.
// Whenever the query parameters change we send a search request to the API.
export const Search = withRouter((props: RouteComponentProps) => {
const {location, history} = props;
const dispatch = useAppDispatch();
const searchResults = useAppSelector((state: AppState) => state?.search?.searchResults || null);
const user = useAppSelector(selectors.user.orNull);
const [urlQuery, urlFilters] = parseLocationSearch(location.search);
const [queryState, setQueryState] = useState(urlQuery);


let initialFilters = urlFilters;
if (isAda && urlFilters.length === 0) {
initialFilters = [DOCUMENT_TYPE.CONCEPT, DOCUMENT_TYPE.TOPIC_SUMMARY, DOCUMENT_TYPE.GENERIC] as SearchableDocumentType[];
}
const [filtersState, setFiltersState] = useState<Item<SearchableDocumentType>[]>(initialFilters.map(itemise));
const [queryState, setQueryState] = useState(urlQuery);

useEffect(function triggerSearchAndUpdateLocalStateOnUrlChange() {
dispatch(fetchSearch(urlQuery ?? "", initialFilters.length ? initialFilters.join(",") : undefined));
setQueryState(urlQuery);
setFiltersState(initialFilters.map(itemise));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [dispatch, location.search]);
// searchQuery is really just {queryState, filtersState}, but updating it triggers a request; we wish to debounce this, so the state is kept separate
const [searchQuery, setSearchQuery] = useState<{query: string; types: string} | typeof skipToken>(skipToken);
const searchResult = useSearchRequestQuery(searchQuery);

function updateSearchUrl(e?: FormEvent<HTMLFormElement>) {
if (e) {e.preventDefault();}
pushSearchToHistory(history, queryState || "", filtersState.map(deitemise));
}
// Trigger update to query on state change
const onUpdate = useMemo(() => {
return debounce((query: Nullable<string>, filters: Item<SearchableDocumentType>[]) => {
setSearchQuery(query ? {query, types: filters.map(deitemise).join(",")} : skipToken);
updateSearchUrl(history, query, filters);
}, 500, {leading: true, trailing: true});
}, [history]);

// Trigger update to search url on query or filter change
const timer: MutableRefObject<number | undefined> = useRef();
useEffect(() => {
if (queryState !== urlQuery) {
timer.current = window.setTimeout(() => {updateSearchUrl();}, 800);
return () => {clearTimeout(timer.current);};
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [queryState]);
onUpdate(queryState, filtersState);
}, [queryState, filtersState, onUpdate]);

useEffect(() => {
const filtersStateMatchesQueryParamFilters =
filtersState.length === initialFilters.length &&
filtersState.map(deitemise).every(f => initialFilters.includes(f));
if (!filtersStateMatchesQueryParamFilters) {
updateSearchUrl();
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [filtersState]);
useEffect(function triggerSearchOnUrlChange() {
setQueryState(urlQuery);
}, [urlQuery]);

// Process results and add shortcut responses
const filteredSearchResults = searchResults?.results && searchResults.results
.filter(result => searchResultIsPublic(result, user));
const shortcutResponses = (queryState ? shortcuts(queryState) : []) as ShortcutResponse[];
const shortcutAndFilteredSearchResults = (shortcutResponses || []).concat(filteredSearchResults || []);
const gotResults = shortcutAndFilteredSearchResults && shortcutAndFilteredSearchResults.length > 0;

const shortcutAndFilterResults = (results?: ContentSummaryDTO[]) => {
const filteredSearchResults = results && results.filter(result => searchResultIsPublic(result, user));
const shortcutResponses = (queryState ? shortcuts(queryState) : []) as ShortcutResponse[];
return (shortcutResponses || []).concat(filteredSearchResults || []);
};

return (
<Container id="search-page">
<TitleAndBreadcrumb currentPageTitle="Search" icon={{type: "hex", icon: "icon-finder"}} />
<SearchPageSearch className={siteSpecific("", "border-theme")} initialValue={urlQuery ?? ""} />
<SearchPageSearch className={siteSpecific("", "border-theme")} initialValue={urlQuery ?? ""} onSearch={setQueryState} />
<Card className="my-4">
<CardHeader className="search-header p-3">
<Col xs={12}>
<h3 className="me-2">
Search Results {urlQuery != "" ? shortcutAndFilteredSearchResults ? <Badge color="primary">{shortcutAndFilteredSearchResults.length}</Badge> : <IsaacSpinner /> : null}
Search Results {urlQuery != "" && isDefined(searchResult?.currentData?.results) ? <Badge color="primary">{searchResult?.currentData?.results.length}</Badge> : null}
</h3>
</Col>
<Col className="d-flex justify-content-end flex-grow-1">
Expand All @@ -147,12 +142,17 @@ export const Search = withRouter((props: RouteComponentProps) => {
</Form>
</Col>
</CardHeader>
{urlQuery != "" && <CardBody className={classNames({"p-0 m-0": isAda && gotResults})}>
<ShowLoading until={shortcutAndFilteredSearchResults}>
{gotResults
? <ListView type="item" items={shortcutAndFilteredSearchResults} hasCaret={isAda}/>
: <em>No results found</em>}
</ShowLoading>
{urlQuery !== "" && <CardBody>
<ShowLoadingQuery
query={searchResult}
defaultErrorTitle="Failed to search. Please try again later."
thenRender={({ results }) => {
const shortcutAndFilteredSearchResults = shortcutAndFilterResults(results);
return shortcutAndFilteredSearchResults.length > 0
? <ListView type="item" items={shortcutAndFilteredSearchResults} hasCaret={isAda}/>
: <em>No results found</em>;
}}
/>
</CardBody>}
</Card>
</Container>
Expand Down
10 changes: 7 additions & 3 deletions src/app/components/site/phy/NavigationMenuPhy.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import { HUMAN_STAGES, HUMAN_SUBJECTS, LearningStage, PATHS, PHY_NAV_STAGES, PHY
import { selectors, useAppSelector } from "../../../state";
import { LoginLogoutButton } from "./HeaderPhy";
import { useAssignmentsCount } from "../../navigation/NavigationBar";
import { Link } from "react-router-dom";
import { Link, useHistory } from "react-router-dom";
import { HoverableNavigationContext, PageContextState } from "../../../../IsaacAppTypes";
import max from "lodash/max";

Expand Down Expand Up @@ -424,6 +424,7 @@ export const NavigationMenuPhy = ({toggleMenu}: {toggleMenu: () => void}) => {
// while moving the mouse between two hoverables, preventing the second dropdown from opening.

const deviceSize = useDeviceSize();
const history = useHistory();

const stageCategories : NavigationCategory[] = Object.entries(PHY_NAV_STAGES).map(([stage, subjects]) => {
const humanStage = HUMAN_STAGES[stage];
Expand Down Expand Up @@ -461,7 +462,10 @@ export const NavigationMenuPhy = ({toggleMenu}: {toggleMenu: () => void}) => {

return <HoverableNavigationContext.Provider value={{openId: openHoverable}}>
{below["sm"](deviceSize) && <div className="w-100 align-self-end d-print-none mb-3">
<MainSearchInput onSearch={toggleMenu}/>
<MainSearchInput onSearch={(s) => {
history.push(`/search?query=${encodeURIComponent(s)}`);
toggleMenu();
}}/>
</div>}

<ContentNavProfile toggleMenu={toggleMenu}/>
Expand All @@ -471,7 +475,7 @@ export const NavigationMenuPhy = ({toggleMenu}: {toggleMenu: () => void}) => {
{above["md"](deviceSize) && <>
<Spacer />
<div className="header-search align-self-center d-print-none">
<MainSearchInput inline />
<MainSearchInput inline onSearch={(s) => history.push(`/search?query=${encodeURIComponent(s)}`)} />
</div>
</>}
</HoverableNavigationContext.Provider>;
Expand Down
3 changes: 0 additions & 3 deletions src/app/services/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -214,9 +214,6 @@ export enum ACTION_TYPE {
TOPIC_RESPONSE_SUCCESS = "TOPIC_RESPONSE_SUCCESS",
TOPIC_RESPONSE_FAILURE = "TOPIC_RESPONSE_FAILURE",

SEARCH_REQUEST = "SEARCH_REQUEST",
SEARCH_RESPONSE_SUCCESS = "SEARCH_RESPONSE_SUCCESS",

TOASTS_SHOW = "TOASTS_SHOW",
TOASTS_HIDE = "TOASTS_HIDE",
TOASTS_REMOVE = "TOASTS_REMOVE",
Expand Down
14 changes: 0 additions & 14 deletions src/app/state/actions/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -583,20 +583,6 @@ export const testQuestion = (questionChoices: FreeTextRule[], testCases: TestCas
}
};

// Search
export const fetchSearch = (query: string, types: string | undefined) => async (dispatch: Dispatch<Action>) => {
dispatch({type: ACTION_TYPE.SEARCH_REQUEST, query, types});
try {
if (query === "") {
return;
}
const searchResponse = await api.search.get(query, types);
dispatch({type: ACTION_TYPE.SEARCH_RESPONSE_SUCCESS, searchResults: searchResponse.data});
} catch (e) {
dispatch(showAxiosErrorToastIfNeeded("Search failed", e));
}
};

// Admin

export const resetMemberPassword = (member: AppGroupMembership) => async (dispatch: Dispatch<Action>) => {
Expand Down
2 changes: 1 addition & 1 deletion src/app/state/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,6 @@ export * from "./slices/doc";
export * from "./slices/linkableSetting";
export * from "./actions/logging";
export * from "./reducers/staticState";
export * from "./reducers/searchState";
export * from "./middleware/userConsistencyCheckerCurrentUser";
export * from "./middleware/hidePreviousQuestionAttempt";
export * from "./reducers/quizState";
Expand Down Expand Up @@ -46,6 +45,7 @@ export * from "./slices/api/questionsApi";
export * from "./slices/api/quizApi";
export * from "./slices/api/miscApi";
export * from "./slices/api/topicsApi";
export * from "./slices/api/searchApi";
export * from "./slices/gameboards";
export * from "./reducers/userState";
export * from "./actions/popups";
Expand Down
4 changes: 0 additions & 4 deletions src/app/state/reducers/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,6 @@ import {
quizAttempt,
groupMemberships,
questionSearchResult,
search,
isaacApi,
gameboardsSlice,
adminUserSearchSlice,
Expand Down Expand Up @@ -83,9 +82,6 @@ export const rootReducer = combineReducers({
// Gameboards
boards: gameboardsSlice.reducer,
questionSearchResult,

// Search
search,

// Quizzes
quizAttempt,
Expand Down
15 changes: 0 additions & 15 deletions src/app/state/reducers/searchState.ts

This file was deleted.

22 changes: 22 additions & 0 deletions src/app/state/slices/api/searchApi.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { ContentSummaryDTO, ResultsWrapper } from "../../../../IsaacApiTypes";
import { isaacApi } from "./baseApi";
import { onQueryLifecycleEvents } from "./utils";

const searchApi = isaacApi.injectEndpoints({
endpoints: (build) => ({
searchRequest: build.query<ResultsWrapper<ContentSummaryDTO> | null, {query: string, types?: string}>({
query: ({query, types}) => ({
url: `/search`,
params: {query, types},
}),

onQueryStarted: onQueryLifecycleEvents({
errorTitle: "Unable to perform search",
}),
})
})
});

export const {
useSearchRequestQuery,
} = searchApi;
Loading
Loading