| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212 |
- import { type JSX, useCallback, useEffect, useMemo, useState } from 'react';
- import { useRouter } from 'next/router';
- import Downshift, {
- type DownshiftState,
- type StateChangeOptions,
- } from 'downshift';
- import { useAtomValue } from 'jotai';
- import { Modal, ModalBody } from 'reactstrap';
- import { useCurrentPagePath } from '~/states/page';
- import { useSetSearchKeyword } from '~/states/search';
- import { isSearchScopeChildrenAsDefaultAtom } from '~/states/server-configurations';
- import { isIncludeAiMenthion, removeAiMenthion } from '../../utils/ai';
- import type { DownshiftItem } from '../interfaces/downshift';
- import {
- useSearchModalActions,
- useSearchModalStatus,
- } from '../states/modal/search';
- import { SearchForm } from './SearchForm';
- import { SearchHelp } from './SearchHelp';
- import { SearchMethodMenuItem } from './SearchMethodMenuItem';
- import { SearchResultMenuItem } from './SearchResultMenuItem';
- type Props = {
- onSearch: (keyword: string) => void;
- };
- const SearchModalSubstance = (props: Props): JSX.Element => {
- const { onSearch } = props;
- const [searchInput, setSearchInput] = useState('');
- const [isMenthionedToAi, setMenthionedToAi] = useState(false);
- const searchModalData = useSearchModalStatus();
- const { close: closeSearchModal } = useSearchModalActions();
- const router = useRouter();
- const changeSearchTextHandler = useCallback((searchText: string) => {
- setSearchInput(searchText);
- }, []);
- const selectSearchMenuItemHandler = useCallback(
- async (selectedItem: DownshiftItem) => {
- await router.push(selectedItem.url);
- closeSearchModal();
- },
- [closeSearchModal, router],
- );
- const submitHandler = useCallback(() => {
- onSearch(searchInput);
- closeSearchModal();
- }, [closeSearchModal, onSearch, searchInput]);
- // Memoize stateReducer to prevent recreation on every render
- const stateReducer = useCallback(
- (
- state: DownshiftState<DownshiftItem>,
- changes: StateChangeOptions<DownshiftItem>,
- ) => {
- // Do not update highlightedIndex on mouse hover
- if (changes.type === Downshift.stateChangeTypes.itemMouseEnter) {
- return {
- ...changes,
- highlightedIndex: state.highlightedIndex,
- };
- }
- return changes;
- },
- [],
- );
- useEffect(() => {
- if (!searchModalData?.isOpened) {
- return;
- }
- if (searchModalData?.searchKeyword == null) {
- setSearchInput('');
- } else {
- setSearchInput(searchModalData.searchKeyword);
- }
- }, [searchModalData?.isOpened, searchModalData?.searchKeyword]);
- useEffect(() => {
- setMenthionedToAi(isIncludeAiMenthion(searchInput));
- }, [searchInput]);
- // Memoize AI mention removal to prevent recalculation on every render
- const searchInputWithoutAi = useMemo(
- () => removeAiMenthion(searchInput),
- [searchInput],
- );
- // Memoize icon selection to prevent recalculation
- const searchIcon = useMemo(
- () => (isMenthionedToAi ? 'psychology' : 'search'),
- [isMenthionedToAi],
- );
- // Memoize icon class to prevent string concatenation on every render
- const iconClassName = useMemo(
- () =>
- `material-symbols-outlined fs-4 me-3 ${isMenthionedToAi ? 'text-primary' : ''}`,
- [isMenthionedToAi],
- );
- return (
- <ModalBody className="pb-2">
- <Downshift
- onSelect={selectSearchMenuItemHandler}
- stateReducer={stateReducer}
- defaultIsOpen
- >
- {({
- getRootProps,
- getInputProps,
- getItemProps,
- getMenuProps,
- highlightedIndex,
- }) => (
- <div {...getRootProps({}, { suppressRefError: true })}>
- <div className="text-muted d-flex justify-content-center align-items-center p-1">
- <span className={iconClassName}>{searchIcon}</span>
- <SearchForm
- searchKeyword={searchInput}
- onChange={changeSearchTextHandler}
- onSubmit={submitHandler}
- getInputProps={getInputProps}
- />
- <button
- type="button"
- className="btn border-0 d-flex justify-content-center p-0"
- onClick={closeSearchModal}
- >
- <span className="material-symbols-outlined fs-4 ms-3 py-0">
- close
- </span>
- </button>
- </div>
- <ul {...getMenuProps()} className="list-unstyled m-0">
- <div className="border-top mt-2 mb-2" />
- <SearchMethodMenuItem
- activeIndex={highlightedIndex}
- searchKeyword={searchInputWithoutAi}
- getItemProps={getItemProps}
- />
- <SearchResultMenuItem
- activeIndex={highlightedIndex}
- searchKeyword={searchInputWithoutAi}
- getItemProps={getItemProps}
- />
- <div className="border-top mt-2 mb-2" />
- </ul>
- </div>
- )}
- </Downshift>
- <SearchHelp />
- </ModalBody>
- );
- };
- const SearchModal = (): JSX.Element => {
- const { isOpened, onSearchOverride } = useSearchModalStatus();
- const { close: closeSearchModal } = useSearchModalActions();
- const setSearchKeyword = useSetSearchKeyword();
- const isSearchScopeChildrenAsDefault = useAtomValue(
- isSearchScopeChildrenAsDefaultAtom,
- );
- const currentPagePath = useCurrentPagePath();
- const searchHandler = useCallback(
- (keyword: string) => {
- // invoke override function if exists
- if (onSearchOverride != null) {
- onSearchOverride(keyword);
- return;
- }
- // Respect the admin setting: scope search to the current page tree by default
- const shouldScopeToChildren =
- isSearchScopeChildrenAsDefault && currentPagePath != null;
- const finalKeyword = shouldScopeToChildren
- ? `prefix:${currentPagePath} ${keyword}`
- : keyword;
- setSearchKeyword(finalKeyword);
- },
- [
- onSearchOverride,
- setSearchKeyword,
- isSearchScopeChildrenAsDefault,
- currentPagePath,
- ],
- );
- return (
- <Modal
- size="lg"
- isOpen={isOpened}
- toggle={closeSearchModal}
- data-testid="search-modal"
- >
- {isOpened && <SearchModalSubstance onSearch={searchHandler} />}
- </Modal>
- );
- };
- export default SearchModal;
|