SearchModal.tsx 6.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212
  1. import { type JSX, useCallback, useEffect, useMemo, useState } from 'react';
  2. import { useRouter } from 'next/router';
  3. import Downshift, {
  4. type DownshiftState,
  5. type StateChangeOptions,
  6. } from 'downshift';
  7. import { useAtomValue } from 'jotai';
  8. import { Modal, ModalBody } from 'reactstrap';
  9. import { useCurrentPagePath } from '~/states/page';
  10. import { useSetSearchKeyword } from '~/states/search';
  11. import { isSearchScopeChildrenAsDefaultAtom } from '~/states/server-configurations';
  12. import { isIncludeAiMenthion, removeAiMenthion } from '../../utils/ai';
  13. import type { DownshiftItem } from '../interfaces/downshift';
  14. import {
  15. useSearchModalActions,
  16. useSearchModalStatus,
  17. } from '../states/modal/search';
  18. import { SearchForm } from './SearchForm';
  19. import { SearchHelp } from './SearchHelp';
  20. import { SearchMethodMenuItem } from './SearchMethodMenuItem';
  21. import { SearchResultMenuItem } from './SearchResultMenuItem';
  22. type Props = {
  23. onSearch: (keyword: string) => void;
  24. };
  25. const SearchModalSubstance = (props: Props): JSX.Element => {
  26. const { onSearch } = props;
  27. const [searchInput, setSearchInput] = useState('');
  28. const [isMenthionedToAi, setMenthionedToAi] = useState(false);
  29. const searchModalData = useSearchModalStatus();
  30. const { close: closeSearchModal } = useSearchModalActions();
  31. const router = useRouter();
  32. const changeSearchTextHandler = useCallback((searchText: string) => {
  33. setSearchInput(searchText);
  34. }, []);
  35. const selectSearchMenuItemHandler = useCallback(
  36. async (selectedItem: DownshiftItem) => {
  37. await router.push(selectedItem.url);
  38. closeSearchModal();
  39. },
  40. [closeSearchModal, router],
  41. );
  42. const submitHandler = useCallback(() => {
  43. onSearch(searchInput);
  44. closeSearchModal();
  45. }, [closeSearchModal, onSearch, searchInput]);
  46. // Memoize stateReducer to prevent recreation on every render
  47. const stateReducer = useCallback(
  48. (
  49. state: DownshiftState<DownshiftItem>,
  50. changes: StateChangeOptions<DownshiftItem>,
  51. ) => {
  52. // Do not update highlightedIndex on mouse hover
  53. if (changes.type === Downshift.stateChangeTypes.itemMouseEnter) {
  54. return {
  55. ...changes,
  56. highlightedIndex: state.highlightedIndex,
  57. };
  58. }
  59. return changes;
  60. },
  61. [],
  62. );
  63. useEffect(() => {
  64. if (!searchModalData?.isOpened) {
  65. return;
  66. }
  67. if (searchModalData?.searchKeyword == null) {
  68. setSearchInput('');
  69. } else {
  70. setSearchInput(searchModalData.searchKeyword);
  71. }
  72. }, [searchModalData?.isOpened, searchModalData?.searchKeyword]);
  73. useEffect(() => {
  74. setMenthionedToAi(isIncludeAiMenthion(searchInput));
  75. }, [searchInput]);
  76. // Memoize AI mention removal to prevent recalculation on every render
  77. const searchInputWithoutAi = useMemo(
  78. () => removeAiMenthion(searchInput),
  79. [searchInput],
  80. );
  81. // Memoize icon selection to prevent recalculation
  82. const searchIcon = useMemo(
  83. () => (isMenthionedToAi ? 'psychology' : 'search'),
  84. [isMenthionedToAi],
  85. );
  86. // Memoize icon class to prevent string concatenation on every render
  87. const iconClassName = useMemo(
  88. () =>
  89. `material-symbols-outlined fs-4 me-3 ${isMenthionedToAi ? 'text-primary' : ''}`,
  90. [isMenthionedToAi],
  91. );
  92. return (
  93. <ModalBody className="pb-2">
  94. <Downshift
  95. onSelect={selectSearchMenuItemHandler}
  96. stateReducer={stateReducer}
  97. defaultIsOpen
  98. >
  99. {({
  100. getRootProps,
  101. getInputProps,
  102. getItemProps,
  103. getMenuProps,
  104. highlightedIndex,
  105. }) => (
  106. <div {...getRootProps({}, { suppressRefError: true })}>
  107. <div className="text-muted d-flex justify-content-center align-items-center p-1">
  108. <span className={iconClassName}>{searchIcon}</span>
  109. <SearchForm
  110. searchKeyword={searchInput}
  111. onChange={changeSearchTextHandler}
  112. onSubmit={submitHandler}
  113. getInputProps={getInputProps}
  114. />
  115. <button
  116. type="button"
  117. className="btn border-0 d-flex justify-content-center p-0"
  118. onClick={closeSearchModal}
  119. >
  120. <span className="material-symbols-outlined fs-4 ms-3 py-0">
  121. close
  122. </span>
  123. </button>
  124. </div>
  125. <ul {...getMenuProps()} className="list-unstyled m-0">
  126. <div className="border-top mt-2 mb-2" />
  127. <SearchMethodMenuItem
  128. activeIndex={highlightedIndex}
  129. searchKeyword={searchInputWithoutAi}
  130. getItemProps={getItemProps}
  131. />
  132. <SearchResultMenuItem
  133. activeIndex={highlightedIndex}
  134. searchKeyword={searchInputWithoutAi}
  135. getItemProps={getItemProps}
  136. />
  137. <div className="border-top mt-2 mb-2" />
  138. </ul>
  139. </div>
  140. )}
  141. </Downshift>
  142. <SearchHelp />
  143. </ModalBody>
  144. );
  145. };
  146. const SearchModal = (): JSX.Element => {
  147. const { isOpened, onSearchOverride } = useSearchModalStatus();
  148. const { close: closeSearchModal } = useSearchModalActions();
  149. const setSearchKeyword = useSetSearchKeyword();
  150. const isSearchScopeChildrenAsDefault = useAtomValue(
  151. isSearchScopeChildrenAsDefaultAtom,
  152. );
  153. const currentPagePath = useCurrentPagePath();
  154. const searchHandler = useCallback(
  155. (keyword: string) => {
  156. // invoke override function if exists
  157. if (onSearchOverride != null) {
  158. onSearchOverride(keyword);
  159. return;
  160. }
  161. // Respect the admin setting: scope search to the current page tree by default
  162. const shouldScopeToChildren =
  163. isSearchScopeChildrenAsDefault && currentPagePath != null;
  164. const finalKeyword = shouldScopeToChildren
  165. ? `prefix:${currentPagePath} ${keyword}`
  166. : keyword;
  167. setSearchKeyword(finalKeyword);
  168. },
  169. [
  170. onSearchOverride,
  171. setSearchKeyword,
  172. isSearchScopeChildrenAsDefault,
  173. currentPagePath,
  174. ],
  175. );
  176. return (
  177. <Modal
  178. size="lg"
  179. isOpen={isOpened}
  180. toggle={closeSearchModal}
  181. data-testid="search-modal"
  182. >
  183. {isOpened && <SearchModalSubstance onSearch={searchHandler} />}
  184. </Modal>
  185. );
  186. };
  187. export default SearchModal;