diff --git a/package.json b/package.json index 9259c8466..84479ae6c 100644 --- a/package.json +++ b/package.json @@ -34,12 +34,6 @@ "require": "./dist/cjs/mp3-encoder.js", "default": "./dist/cjs/mp3-encoder.js" }, - "./experimental": { - "types": "./dist/types/experimental/index.d.ts", - "import": "./dist/es/experimental.mjs", - "require": "./dist/cjs/experimental.js", - "default": "./dist/cjs/experimental.js" - }, "./dist/css/*": { "default": "./dist/css/*" }, diff --git a/src/components/Channel/Channel.tsx b/src/components/Channel/Channel.tsx index 4bfe523c1..3dd609bc5 100644 --- a/src/components/Channel/Channel.tsx +++ b/src/components/Channel/Channel.tsx @@ -87,7 +87,7 @@ import { getImageAttachmentConfiguration, getVideoAttachmentConfiguration, } from '../Attachment/attachment-sizing'; -import { useSearchFocusedMessage } from '../../experimental/Search/hooks'; +import { useSearchFocusedMessage } from '../Search/hooks'; import { WithAudioPlayback } from '../AudioPlayback'; export type ChannelProps = { diff --git a/src/components/ChannelList/ChannelList.tsx b/src/components/ChannelList/ChannelList.tsx index 4d87e0187..2a14dfb84 100644 --- a/src/components/ChannelList/ChannelList.tsx +++ b/src/components/ChannelList/ChannelList.tsx @@ -21,7 +21,7 @@ import { useStateStore } from '../../store'; import { ChannelListMessenger } from './ChannelListMessenger'; import { Avatar as DefaultAvatar } from '../Avatar'; import { ChannelPreview } from '../ChannelPreview/ChannelPreview'; -import { ChannelSearch as DefaultChannelSearch } from '../ChannelSearch/ChannelSearch'; +import { Search as DefaultSearch } from '../Search'; import { EmptyStateIndicator as DefaultEmptyStateIndicator } from '../EmptyStateIndicator'; import { LoadingChannels } from '../Loading/LoadingChannels'; import { LoadMorePaginator } from '../LoadMore/LoadMorePaginator'; @@ -36,7 +36,7 @@ import { moveChannelUpwards } from './utils'; import type { CustomQueryChannelsFn } from './hooks/usePaginatedChannels'; import type { ChannelListMessengerProps } from './ChannelListMessenger'; import type { ChannelPreviewUIComponentProps } from '../ChannelPreview/ChannelPreview'; -import type { ChannelSearchProps } from '../ChannelSearch/ChannelSearch'; +import type { SearchProps } from '../Search'; import type { EmptyStateIndicatorProps } from '../EmptyStateIndicator'; import type { LoadMorePaginatorProps } from '../LoadMore/LoadMorePaginator'; import type { ChatContextValue } from '../../context'; @@ -56,8 +56,6 @@ const searchControllerStateSelector = (nextValue: SearchControllerState) => ({ }); export type ChannelListProps = { - /** Additional props for underlying ChannelSearch component and channel search controller, [available props](https://getstream.io/chat/docs/sdk/react/utility-components/channel_search/#props) */ - additionalChannelSearchProps?: Omit; /** * When the client receives `message.new`, `notification.message_new`, and `notification.added_to_channel` events, we automatically * push that channel to the top of the list. If the channel doesn't currently exist in the list, we grab the channel from @@ -69,8 +67,8 @@ export type ChannelListProps = { Avatar?: React.ComponentType; /** Optional function to filter channels prior to loading in the DOM. Do not use any complex or async logic that would delay the loading of the ChannelList. We recommend using a pure function with array methods like filter/sort/reduce. */ channelRenderFilterFn?: (channels: Array) => Array; - /** Custom UI component to display search results, defaults to and accepts same props as: [ChannelSearch](https://github.com/GetStream/stream-chat-react/blob/master/src/components/ChannelSearch/ChannelSearch.tsx) */ - ChannelSearch?: React.ComponentType; + /** Custom UI component to display search results, defaults to and accepts same props as: [Search](https://github.com/GetStream/stream-chat-react/blob/master/src/components/ChannelSearch/Search.tsx) */ + ChannelSearch?: React.ComponentType; // FIXME: how is this even legal (WHY IS IT STRING?!) /** Set a channel (with this ID) to active and manually move it to the top of the list */ customActiveChannel?: string; @@ -172,11 +170,9 @@ export type ChannelListProps = { const UnMemoizedChannelList = (props: ChannelListProps) => { const { - additionalChannelSearchProps, allowNewMessagesFromUnfilteredChannels = true, Avatar = DefaultAvatar, channelRenderFilterFn, - ChannelSearch = DefaultChannelSearch, customActiveChannel, customQueryChannels, EmptyStateIndicator = DefaultEmptyStateIndicator, @@ -221,10 +217,9 @@ const UnMemoizedChannelList = (props: ChannelListProps) => { theme, useImageFlagEmojisOnWindows, } = useChatContext('ChannelList'); - const { Search } = useComponentContext(); // FIXME: us component context to retrieve ChannelPreview UI components too + const { Search = DefaultSearch } = useComponentContext(); // FIXME: use component context to retrieve ChannelPreview UI components too const channelListRef = useRef(null); const [channelUpdateCount, setChannelUpdateCount] = useState(0); - const [searchActive, setSearchActive] = useState(false); // Indicator relevant when Search component that relies on SearchController is used const { searchIsActive } = useStateStore( @@ -280,20 +275,6 @@ const UnMemoizedChannelList = (props: ChannelListProps) => { */ const forceUpdate = useCallback(() => setChannelUpdateCount((count) => count + 1), []); - const onSearch = useCallback( - (event: React.ChangeEvent) => { - setSearchActive(!!event.target.value); - - additionalChannelSearchProps?.onSearch?.(event); - }, - [additionalChannelSearchProps], - ); - - const onSearchExit = useCallback(() => { - setSearchActive(false); - additionalChannelSearchProps?.onSearchExit?.(); - }, [additionalChannelSearchProps]); - const { channels, hasNextPage, loadNextPage, setChannels } = usePaginatedChannels( client, filters || DEFAULT_FILTERS, @@ -379,8 +360,7 @@ const UnMemoizedChannelList = (props: ChannelListProps) => { }, ); - const showChannelList = - (!searchActive && !searchIsActive) || additionalChannelSearchProps?.popupResults; + const showChannelList = !searchIsActive; return ( { >
- {showChannelSearch && - (Search ? ( - - ) : ( - - ))} + {showChannelSearch && } {showChannelList && ( ; - /** Custom UI component to display the search text input */ - SearchInput?: React.ComponentType; -}; - -export type ChannelSearchProps = AdditionalSearchBarProps & - AdditionalSearchInputProps & - AdditionalSearchResultsProps & - AdditionalChannelSearchProps & - ChannelSearchControllerParams; - -const UnMemoizedChannelSearch = (props: ChannelSearchProps) => { - const { - AppMenu, - ClearInputIcon, - ExitSearchIcon, - MenuIcon, - placeholder, - popupResults = false, - SearchBar = DefaultSearchBar, - SearchEmpty, - SearchInput = DefaultSearchInput, - SearchInputIcon, - SearchLoading, - SearchResultItem, - SearchResultsHeader, - SearchResultsList, - ...channelSearchParams - } = props; - - const { - activateSearch, - clearState, - exitSearch, - inputIsFocused, - inputRef, - onSearch, - query, - results, - searchBarRef, - searching, - selectResult, - } = useChannelSearch(channelSearchParams); - - return ( -
0, - }, - )} - data-testid='channel-search' - > - - - {query && ( - - )} -
- ); -}; - -/** - * The ChannelSearch component makes a query users call and displays the results in a list. - * Clicking on a list item will navigate you into a channel with the selected user. It can be used - * on its own or added to the ChannelList component by setting the `showChannelSearch` prop to true. - */ -export const ChannelSearch = React.memo( - UnMemoizedChannelSearch, -) as typeof UnMemoizedChannelSearch; diff --git a/src/components/ChannelSearch/SearchBar.tsx b/src/components/ChannelSearch/SearchBar.tsx deleted file mode 100644 index a409db48b..000000000 --- a/src/components/ChannelSearch/SearchBar.tsx +++ /dev/null @@ -1,187 +0,0 @@ -import type { MouseEventHandler, PropsWithChildren } from 'react'; -import React, { useCallback, useEffect, useRef, useState } from 'react'; -import clsx from 'clsx'; - -import { - MenuIcon as DefaultMenuIcon, - SearchIcon as DefaultSearchInputIcon, - ReturnIcon, - XIcon, -} from './icons'; -import type { SearchInputProps } from './SearchInput'; -import { SearchInput as DefaultSearchInput } from './SearchInput'; - -export type AppMenuProps = { - close?: () => void; -}; - -type SearchBarButtonProps = { - className?: string; - onClick?: MouseEventHandler; -}; - -const SearchBarButton = ({ - children, - className, - onClick, -}: PropsWithChildren) => ( - -); - -export type SearchBarController = { - /** Called on search input focus */ - activateSearch: () => void; - /** Clears the search state, removes focus from the search input */ - exitSearch: () => void; - /** Flag determining whether the search input is focused */ - inputIsFocused: boolean; - /** Ref object for the input wrapper in the SearchBar */ - searchBarRef: React.RefObject; -}; - -export type AdditionalSearchBarProps = { - /** Application menu to be displayed when clicked on MenuIcon */ - AppMenu?: React.ComponentType; - /** Custom icon component used to clear the input value on click. Displayed within the search input wrapper. */ - ClearInputIcon?: React.ComponentType; - /** Custom icon component used to terminate the search UI session on click. */ - ExitSearchIcon?: React.ComponentType; - /** Custom icon component used to invoke context menu. */ - MenuIcon?: React.ComponentType; - /** Custom UI component to display the search text input */ - SearchInput?: React.ComponentType; - /** Custom icon used to indicate search input. */ - SearchInputIcon?: React.ComponentType; -}; - -export type SearchBarProps = AdditionalSearchBarProps & - SearchBarController & - SearchInputProps; - -// todo: add context menu control logic -export const SearchBar = (props: SearchBarProps) => { - const { - activateSearch, - AppMenu, - ClearInputIcon = XIcon, - exitSearch, - ExitSearchIcon = ReturnIcon, - inputIsFocused, - MenuIcon = DefaultMenuIcon, - searchBarRef, - SearchInput = DefaultSearchInput, - SearchInputIcon = DefaultSearchInputIcon, - ...inputProps - } = props; - - const [menuIsOpen, setMenuIsOpen] = useState(false); - const appMenuRef = useRef(null); - - useEffect(() => { - if (!appMenuRef.current) return; - - const handleKeyDown = (event: KeyboardEvent) => { - if (menuIsOpen && event.key === 'Escape') { - setMenuIsOpen(false); - } - }; - - const clickListener = (e: MouseEvent) => { - if ( - !(e.target instanceof HTMLElement) || - !menuIsOpen || - appMenuRef.current?.contains(e.target) - ) - return; - setMenuIsOpen(false); - }; - - document.addEventListener('keydown', handleKeyDown); - document.addEventListener('click', clickListener); - return () => { - document.removeEventListener('keydown', handleKeyDown); - document.removeEventListener('click', clickListener); - }; - }, [menuIsOpen]); - - useEffect(() => { - if (!props.inputRef.current) return; - const input = props.inputRef.current; - const handleFocus = () => { - activateSearch(); - }; - - const handleBlur = (e: Event) => { - e.stopPropagation(); // handle blur/focus state with React state - }; - - input.addEventListener('focus', handleFocus); - input.addEventListener('blur', handleBlur); - return () => { - input.removeEventListener('focus', handleFocus); - input.removeEventListener('blur', handleBlur); - }; - }, [activateSearch, props.inputRef]); - - const handleClearClick = useCallback(() => { - exitSearch(); - inputProps.inputRef.current?.focus(); - }, [exitSearch, inputProps.inputRef]); - - const closeAppMenu = useCallback(() => setMenuIsOpen(false), []); - - return ( -
- {inputIsFocused ? ( - - - - ) : AppMenu ? ( - setMenuIsOpen((prev) => !prev)} - > - - - ) : null} - -
-
- -
- - -
- {menuIsOpen && AppMenu && ( -
- -
- )} -
- ); -}; diff --git a/src/components/ChannelSearch/SearchInput.tsx b/src/components/ChannelSearch/SearchInput.tsx deleted file mode 100644 index 21a082d05..000000000 --- a/src/components/ChannelSearch/SearchInput.tsx +++ /dev/null @@ -1,41 +0,0 @@ -import React from 'react'; - -import { useTranslationContext } from '../../context/TranslationContext'; - -export type SearchInputController = { - /** Clears the channel search state */ - clearState: () => void; - inputRef: React.RefObject; - /** Search input change handler */ - onSearch: React.ChangeEventHandler; - /** Current search string */ - query: string; -}; - -export type AdditionalSearchInputProps = { - /** Sets the input element into disabled state */ - disabled?: boolean; - /** Custom placeholder text to be displayed in the search input */ - placeholder?: string; -}; - -export type SearchInputProps = AdditionalSearchInputProps & SearchInputController; - -export const SearchInput = (props: SearchInputProps) => { - const { disabled, inputRef, onSearch, placeholder, query } = props; - - const { t } = useTranslationContext('SearchInput'); - - return ( - - ); -}; diff --git a/src/components/ChannelSearch/SearchResults.tsx b/src/components/ChannelSearch/SearchResults.tsx deleted file mode 100644 index ff52cd5c7..000000000 --- a/src/components/ChannelSearch/SearchResults.tsx +++ /dev/null @@ -1,241 +0,0 @@ -import type { PropsWithChildren } from 'react'; -import React, { useCallback, useEffect, useState } from 'react'; -import clsx from 'clsx'; - -import { SearchIcon } from './icons'; -import { ChannelPreview } from '../ChannelPreview'; -import type { ChannelOrUserResponse } from './utils'; -import { isChannel } from './utils'; -import { Avatar } from '../Avatar'; - -import { useTranslationContext } from '../../context'; - -const DefaultSearchEmpty = () => { - const { t } = useTranslationContext('SearchResults'); - return ( -
- - {t('No results found')} -
- ); -}; - -export type SearchResultsHeaderProps = Pick; - -const DefaultSearchResultsHeader = ({ results }: SearchResultsHeaderProps) => { - const { t } = useTranslationContext('SearchResultsHeader'); - return ( -
- {t('searchResultsCount', { - count: results.length, - })} -
- ); -}; - -export type SearchResultsListProps = Required< - Pick -> & { - focusedUser?: number; -}; - -const DefaultSearchResultsList = (props: SearchResultsListProps) => { - const { focusedUser, results, SearchResultItem, selectResult } = props; - - return ( - <> - {results.map((result, index) => ( - - ))} - - ); -}; - -// fixme: index and focusedUser should be changed for className with default value "str-chat__channel-search-result--focused" -export type SearchResultItemProps = Pick & { - index: number; - result: ChannelOrUserResponse; - focusedUser?: number; -}; - -const DefaultSearchResultItem = (props: SearchResultItemProps) => { - const { focusedUser, index, result, selectResult } = props; - const focused = focusedUser === index; - - const className = clsx( - 'str-chat__channel-search-result', - focused && 'str-chat__channel-search-result--focused', - ); - - if (isChannel(result)) { - const channel = result; - - return ( - selectResult(channel)} - /> - ); - } else { - return ( - - ); - } -}; - -const ResultsContainer = ({ - children, - popupResults, -}: PropsWithChildren<{ popupResults?: boolean }>) => { - const { t } = useTranslationContext('ResultsContainer'); - - return ( -
- {children} -
- ); -}; - -export type SearchResultsController = { - results: Array; - searching: boolean; - selectResult: (result: ChannelOrUserResponse) => Promise | void; -}; - -export type AdditionalSearchResultsProps = { - /** Display search results as an absolutely positioned popup, defaults to false and shows inline */ - popupResults?: boolean; - /** Custom UI component to display empty search results */ - SearchEmpty?: React.ComponentType; - /** Custom UI component to display the search loading state */ - SearchLoading?: React.ComponentType; - /** Custom UI component to display a search result list item, defaults to and accepts the same props as: [DefaultSearchResultItem](https://github.com/GetStream/stream-chat-react/blob/master/src/components/ChannelSearch/SearchResults.tsx) */ - SearchResultItem?: React.ComponentType; - /** Custom UI component to display the search results header */ - SearchResultsHeader?: React.ComponentType; - /** Custom UI component to display all the search results, defaults to and accepts the same props as: [DefaultSearchResultsList](https://github.com/GetStream/stream-chat-react/blob/master/src/components/ChannelSearch/SearchResults.tsx) */ - SearchResultsList?: React.ComponentType; -}; - -export type SearchResultsProps = AdditionalSearchResultsProps & SearchResultsController; - -export const SearchResults = (props: SearchResultsProps) => { - const { - popupResults, - results, - SearchEmpty = DefaultSearchEmpty, - searching, - SearchLoading, - SearchResultItem = DefaultSearchResultItem, - SearchResultsHeader = DefaultSearchResultsHeader, - SearchResultsList = DefaultSearchResultsList, - selectResult, - } = props; - - const { t } = useTranslationContext('SearchResults'); - const [focusedResult, setFocusedResult] = useState(); - - const handleKeyDown = useCallback( - (event: KeyboardEvent) => { - if (event.key === 'ArrowUp') { - setFocusedResult((prevFocused) => { - if (prevFocused === undefined) return 0; - return prevFocused === 0 ? results.length - 1 : prevFocused - 1; - }); - } - - if (event.key === 'ArrowDown') { - setFocusedResult((prevFocused) => { - if (prevFocused === undefined) return 0; - return prevFocused === results.length - 1 ? 0 : prevFocused + 1; - }); - } - - if (event.key === 'Enter') { - event.preventDefault(); - setFocusedResult((prevFocused) => { - if (typeof prevFocused !== 'undefined') { - selectResult(results[prevFocused]); - return undefined; - } - return prevFocused; - }); - } - }, - [results, selectResult], - ); - - useEffect(() => { - document.addEventListener('keydown', handleKeyDown, false); - return () => document.removeEventListener('keydown', handleKeyDown); - }, [handleKeyDown]); - - if (searching) { - return ( - - {SearchLoading ? ( - - ) : ( -
- {t('Searching...')} -
- )} -
- ); - } - - if (!results.length) { - return ( - - - - ); - } - - return ( - - - - - ); -}; diff --git a/src/components/ChannelSearch/__tests__/ChannelSearch.test.js b/src/components/ChannelSearch/__tests__/ChannelSearch.test.js deleted file mode 100644 index c113abdb1..000000000 --- a/src/components/ChannelSearch/__tests__/ChannelSearch.test.js +++ /dev/null @@ -1,453 +0,0 @@ -import React from 'react'; -import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'; -import '@testing-library/jest-dom'; - -import { ChannelSearch } from '../ChannelSearch'; -import { - generateChannel, - generateUser, - getTestClientWithUser, - queryUsersApi, - useMockedApis, -} from '../../../mock-builders'; -import { ChatProvider } from '../../../context'; - -let chatClient; -const user = generateUser({ id: 'id', name: 'name' }); -const channelResponseData = generateChannel(); - -const DEFAULT_DEBOUNCE_INTERVAL = 300; -const TEST_ID = { - CHANNEL_SEARCH: 'channel-search', - CHANNEL_SEARCH_RESULTS_HEADER: 'channel-search-results-header', - CLEAR_INPUT_BUTTON: 'clear-input-button', - SEARCH_IN_PROGRESS_INDICATOR: 'search-in-progress-indicator', - SEARCH_INPUT: 'search-input', -}; -const typedText = 'abc'; - -const renderSearch = async ({ client, props } = { props: {} }) => { - chatClient = client || (await getTestClientWithUser(user)); - - const renderResult = await act(() => { - render( - - - , - ); - }); - - const channelSearch = await waitFor(() => screen.getByTestId(TEST_ID.CHANNEL_SEARCH)); - const searchInput = await waitFor(() => screen.getByTestId(TEST_ID.SEARCH_INPUT)); - - const typeText = (text) => { - fireEvent.change(searchInput, { target: { value: text } }); - }; - return { ...renderResult, channelSearch, chatClient, searchInput, typeText }; -}; - -describe('ChannelSearch', () => { - afterEach(cleanup); - - it('should render component without any props', async () => { - const { channelSearch } = await renderSearch(); - - expect(channelSearch).toMatchSnapshot(); - }); - - it('displays custom placeholder', async () => { - const placeholder = 'Custom placeholder'; - const { channelSearch } = await renderSearch({ props: { placeholder } }); - expect(channelSearch).toMatchSnapshot(); - }); - - it('updates search query value upon each stroke', async () => { - const { searchInput, typeText } = await renderSearch(); - await act(() => { - typeText(typedText); - }); - - await waitFor(() => { - expect(searchInput).toHaveValue(typedText); - }); - }); - - it('does not update input search query value when disabled', async () => { - const { searchInput, typeText } = await renderSearch({ props: { disabled: true } }); - await act(() => { - typeText(typedText); - }); - - await waitFor(() => { - expect(searchInput).toHaveValue(''); - }); - }); - - it('starts with "searching" flag disabled', async () => { - await renderSearch(); - expect( - screen.queryByTestId(TEST_ID.SEARCH_IN_PROGRESS_INDICATOR), - ).not.toBeInTheDocument(); - }); - - it('sets "searching" flag on first typing stroke', async () => { - const { typeText } = await renderSearch(); - await act(() => { - typeText(typedText); - }); - expect( - screen.queryByTestId(TEST_ID.SEARCH_IN_PROGRESS_INDICATOR), - ).toBeInTheDocument(); - }); - - it('removes "searching" flag upon deleting the last character', async () => { - const { typeText } = await renderSearch(); - await act(() => { - typeText(typedText); - }); - expect( - screen.queryByTestId(TEST_ID.SEARCH_IN_PROGRESS_INDICATOR), - ).toBeInTheDocument(); - await act(() => { - typeText(''); - }); - expect( - screen.queryByTestId(TEST_ID.SEARCH_IN_PROGRESS_INDICATOR), - ).not.toBeInTheDocument(); - }); - - it('removes "searching" flag upon setting search results', async () => { - jest.useFakeTimers(); - const client = await getTestClientWithUser(user); - useMockedApis(client, [queryUsersApi([user])]); - const { typeText } = await renderSearch({ client }); - await act(() => { - typeText(typedText); - }); - expect( - screen.queryByTestId(TEST_ID.SEARCH_IN_PROGRESS_INDICATOR), - ).toBeInTheDocument(); - - await act(() => { - jest.advanceTimersByTime(1000); - }); - - await waitFor(() => { - expect( - screen.queryByTestId(TEST_ID.SEARCH_IN_PROGRESS_INDICATOR), - ).not.toBeInTheDocument(); - }); - jest.useRealTimers(); - }); - - it('search is performed by default on users and not channels', async () => { - const limit = 8; - const otherUsers = Array.from({ length: limit }, generateUser); - jest.useFakeTimers('modern'); - const client = await getTestClientWithUser(user); - jest.spyOn(client, 'queryUsers').mockResolvedValue({ users: [...otherUsers, user] }); - jest.spyOn(client, 'queryChannels').mockImplementation(); - const { typeText } = await renderSearch({ client }); - await act(() => { - typeText(typedText); - }); - - await act(() => { - jest.advanceTimersByTime(1000); - }); - - expect(client.queryUsers).toHaveBeenCalledWith( - expect.objectContaining({ - $or: [ - { id: { $autocomplete: typedText } }, - { name: { $autocomplete: typedText } }, - ], - }), - { id: 1 }, - { limit }, - ); - expect(client.queryUsers).toHaveBeenCalledTimes(1); - expect(client.queryChannels).not.toHaveBeenCalled(); - otherUsers.forEach((user) => { - expect(screen.queryByText(user.name)).toBeInTheDocument(); - }); - expect(screen.queryByText(user.name)).not.toBeInTheDocument(); - - jest.useRealTimers(); - }); - - it('search is performed on users and channels if enabled', async () => { - const limit = 8; - const otherUsers = Array.from({ length: limit }, generateUser); - jest.useFakeTimers('modern'); - const client = await getTestClientWithUser(user); - jest.spyOn(client, 'queryUsers').mockResolvedValue({ users: [...otherUsers, user] }); - jest.spyOn(client, 'queryChannels').mockResolvedValue([channelResponseData]); - - const { typeText } = await renderSearch({ - client, - props: { searchForChannels: true }, - }); - await act(() => { - typeText(typedText); - }); - - await act(() => { - jest.advanceTimersByTime(1000); - }); - - expect(client.queryUsers).toHaveBeenCalledTimes(1); - expect(client.queryChannels).toHaveBeenCalledTimes(1); - otherUsers.forEach((user) => { - expect(screen.queryByText(user.name)).toBeInTheDocument(); - }); - expect(screen.queryByText(user.name)).not.toBeInTheDocument(); - jest.useRealTimers(); - }); - - it('search is performed on channels only', async () => { - const limit = 8; - const otherUsers = Array.from({ length: limit }, generateUser); - jest.useFakeTimers('modern'); - const client = await getTestClientWithUser(user); - jest.spyOn(client, 'queryUsers').mockResolvedValue({ users: [...otherUsers, user] }); - jest.spyOn(client, 'queryChannels').mockResolvedValue([channelResponseData]); - - const { typeText } = await renderSearch({ - client, - props: { searchForChannels: true, searchForUsers: false }, - }); - await act(() => { - typeText(typedText); - }); - - await act(() => { - jest.advanceTimersByTime(1000); - }); - - expect(client.queryUsers).not.toHaveBeenCalled(); - expect(client.queryChannels).toHaveBeenCalledTimes(1); - jest.useRealTimers(); - }); - - it('search is not performed on channels neither users', async () => { - const limit = 8; - const otherUsers = Array.from({ length: limit }, generateUser); - jest.useFakeTimers('modern'); - const client = await getTestClientWithUser(user); - jest.spyOn(client, 'queryUsers').mockResolvedValue({ users: [...otherUsers, user] }); - jest.spyOn(client, 'queryChannels').mockResolvedValue([channelResponseData]); - - const { typeText } = await renderSearch({ - client, - props: { searchForChannels: false, searchForUsers: false }, - }); - await act(() => { - typeText(typedText); - }); - - await act(() => { - jest.advanceTimersByTime(1000); - }); - - expect(client.queryUsers).not.toHaveBeenCalled(); - expect(client.queryChannels).not.toHaveBeenCalled(); - jest.useRealTimers(); - }); - - it('does not perform search queries when the search is disabled', async () => { - jest.useFakeTimers('modern'); - const client = await getTestClientWithUser(user); - jest.spyOn(client, 'queryUsers').mockResolvedValue({ users: [user] }); - jest.spyOn(client, 'queryChannels').mockImplementation(); - const { typeText } = await renderSearch({ client, props: { disabled: true } }); - await act(() => { - typeText(typedText); - }); - - await act(() => { - jest.advanceTimersByTime(1000); - }); - - expect(client.queryUsers).not.toHaveBeenCalled(); - expect(client.queryChannels).not.toHaveBeenCalled(); - - jest.useRealTimers(); - }); - - it('ignores the queries in progress upon clearing the input', async () => { - jest.useFakeTimers('modern'); - const client = await getTestClientWithUser(user); - jest.spyOn(client, 'queryUsers').mockResolvedValue({ users: [user] }); - - const { typeText } = await renderSearch({ client }); - await act(() => { - typeText(typedText); - }); - - await act(() => { - jest.advanceTimersByTime(DEFAULT_DEBOUNCE_INTERVAL); - }); - - await act(() => { - fireEvent.click(screen.getByTestId(TEST_ID.CLEAR_INPUT_BUTTON)); - }); - - expect(client.queryUsers).toHaveBeenCalledTimes(1); - expect( - screen.queryByTestId(TEST_ID.CHANNEL_SEARCH_RESULTS_HEADER), - ).not.toBeInTheDocument(); - expect( - screen.queryByTestId(TEST_ID.SEARCH_IN_PROGRESS_INDICATOR), - ).not.toBeInTheDocument(); - jest.useRealTimers(); - }); - - it('ignores the queries in progress upon deleting the last character', async () => { - jest.useFakeTimers('modern'); - const client = await getTestClientWithUser(user); - jest.spyOn(client, 'queryUsers').mockResolvedValue({ users: [user] }); - - const { typeText } = await renderSearch({ client }); - await act(() => { - typeText(typedText); - }); - - await act(() => { - jest.advanceTimersByTime(DEFAULT_DEBOUNCE_INTERVAL); - }); - - await act(() => { - typeText(''); - }); - - expect(client.queryUsers).toHaveBeenCalledTimes(1); - expect( - screen.queryByTestId(TEST_ID.CHANNEL_SEARCH_RESULTS_HEADER), - ).not.toBeInTheDocument(); - expect( - screen.queryByTestId(TEST_ID.SEARCH_IN_PROGRESS_INDICATOR), - ).not.toBeInTheDocument(); - jest.useRealTimers(); - }); - - it('debounces the queries upon typing', async () => { - jest.useFakeTimers('modern'); - const textToQuery = 'x'; - const client = await getTestClientWithUser(user); - jest.spyOn(client, 'queryUsers').mockResolvedValue({ users: [user] }); - - const { typeText } = await renderSearch({ client }); - await act(() => { - typeText(typedText); - }); - - await act(() => { - jest.advanceTimersByTime(DEFAULT_DEBOUNCE_INTERVAL - 1); - }); - - expect(client.queryUsers).not.toHaveBeenCalled(); - - await act(() => { - typeText(textToQuery); - }); - - await act(() => { - jest.advanceTimersByTime(DEFAULT_DEBOUNCE_INTERVAL); - }); - - expect(client.queryUsers).toHaveBeenCalledTimes(1); - expect(client.queryUsers).toHaveBeenCalledWith( - expect.objectContaining({ - $or: [ - { id: { $autocomplete: textToQuery } }, - { name: { $autocomplete: textToQuery } }, - ], - }), - { id: 1 }, - { limit: 8 }, - ); - jest.useRealTimers(); - }); - - it('allows to configure the search query debounce interval', async () => { - jest.useFakeTimers('modern'); - const textToQuery = 'x'; - const newDebounceInterval = DEFAULT_DEBOUNCE_INTERVAL - 100; - const client = await getTestClientWithUser(user); - jest.spyOn(client, 'queryUsers').mockResolvedValue({ users: [user] }); - - const { typeText } = await renderSearch({ - client, - props: { searchDebounceIntervalMs: newDebounceInterval }, - }); - await act(() => { - typeText(typedText); - }); - - await act(() => { - jest.advanceTimersByTime(newDebounceInterval - 1); - }); - - expect(client.queryUsers).not.toHaveBeenCalled(); - - await act(() => { - typeText(textToQuery); - }); - - await act(() => { - jest.advanceTimersByTime(newDebounceInterval); - }); - - expect(client.queryUsers).toHaveBeenCalledTimes(1); - expect(client.queryUsers).toHaveBeenCalledWith( - expect.objectContaining({ - $or: [ - { id: { $autocomplete: textToQuery } }, - { name: { $autocomplete: textToQuery } }, - ], - }), - { id: 1 }, - { limit: 8 }, - ); - jest.useRealTimers(); - }); - - it('calls custom search function instead of the default', async () => { - jest.useFakeTimers('modern'); - const searchFunction = jest.fn(); - const client = await getTestClientWithUser(user); - jest.spyOn(client, 'queryUsers').mockResolvedValue({ users: [user] }); - const { typeText } = await renderSearch({ client, props: { searchFunction } }); - await act(() => { - typeText(typedText); - }); - - await act(() => { - jest.advanceTimersByTime(DEFAULT_DEBOUNCE_INTERVAL); - }); - - expect(client.queryUsers).not.toHaveBeenCalled(); - expect(searchFunction).toHaveBeenCalledTimes(1); - jest.useRealTimers(); - }); - - it('calls custom onSearch callback', async () => { - jest.useFakeTimers('modern'); - const onSearch = jest.fn(); - const client = await getTestClientWithUser(user); - jest.spyOn(client, 'queryUsers').mockResolvedValue({ users: [user] }); - const { typeText } = await renderSearch({ client, props: { onSearch } }); - await act(() => { - typeText(typedText); - }); - - await act(() => { - jest.advanceTimersByTime(DEFAULT_DEBOUNCE_INTERVAL); - }); - - expect(client.queryUsers).toHaveBeenCalledTimes(1); - expect(onSearch).toHaveBeenCalledTimes(1); - jest.useRealTimers(); - }); -}); diff --git a/src/components/ChannelSearch/__tests__/SearchBar.test.js b/src/components/ChannelSearch/__tests__/SearchBar.test.js deleted file mode 100644 index f434333ee..000000000 --- a/src/components/ChannelSearch/__tests__/SearchBar.test.js +++ /dev/null @@ -1,364 +0,0 @@ -import React from 'react'; -import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'; - -import '@testing-library/jest-dom'; - -import { SearchBar } from '../SearchBar'; -import { Chat } from '../../Chat'; -import { useChannelSearch } from '../hooks/useChannelSearch'; - -import { - generateUser, - getTestClientWithUser, - queryUsersApi, - useMockedApis, -} from '../../../mock-builders'; - -let client; -const inputText = new Date().getTime().toString(); - -const AppMenu = ({ close }) => ( -
- AppMenu -
-
-); -const ClearInputIcon = () =>
CustomClearInputIcon
; -const MenuIcon = () =>
CustomMenuIcon
; -const SearchInputIcon = () =>
CustomSearchInputIcon
; - -const SearchContainer = ({ props = {}, searchParams }) => { - const controller = useChannelSearch(searchParams); - return ; -}; - -const renderComponent = ({ client, props = {}, searchParams }) => - render( - - - , - ); - -describe('SearchBar', () => { - beforeEach(async () => { - const user = generateUser(); - client = await getTestClientWithUser({ id: user.id }); - useMockedApis(client, [queryUsersApi([user])]); - }); - - it.each([ - ['enable', false, 'xxxxxxxxxx', 'xxxxxxxxxx'], - ['disable', true, 'xxxxxxxxxx', ''], - ])('should %s typing', async (_, disabled, inputText, expectedValue) => { - await renderComponent({ client, searchParams: { disabled } }); - - const input = screen.queryByTestId('search-input'); - - await act(() => { - fireEvent.change(input, { - target: { - value: inputText, - }, - }); - }); - - await waitFor(() => { - expect(input).toHaveValue(expectedValue); - }); - }); - it('should render default layout', () => { - expect( - render( - , - ).container, - ).toMatchSnapshot(); - }); - it.each([ - ['should not render', undefined], - ['should render', AppMenu], - ])('%s menu icon', async (_, AppMenu) => { - await render( - , - ); - await waitFor(() => { - if (!AppMenu) { - expect(screen.queryByTestId('menu-icon')).not.toBeInTheDocument(); - } else { - expect(screen.queryByTestId('menu-icon')).toBeInTheDocument(); - } - }); - }); - it('should render custom icons', async () => { - await render( - , - ); - expect(screen.queryByText('CustomClearInputIcon')).toBeInTheDocument(); - expect(screen.queryByText('CustomMenuIcon')).toBeInTheDocument(); - expect(screen.queryByText('CustomSearchInputIcon')).toBeInTheDocument(); - }); - - it('should not render ExitSearchIcon if input is not focused', async () => { - await act(() => { - renderComponent({ client, searchParams: { disabled: false } }); - }); - - await waitFor(() => { - expect(screen.queryByTestId('return-icon')).not.toBeInTheDocument(); - }); - }); - - it('should render ExitSearchIcon on input focus', async () => { - await renderComponent({ client, searchParams: { disabled: false } }); - - const input = screen.queryByTestId('search-input'); - - await act(() => { - fireEvent.focus(input); - }); - - await waitFor(() => { - expect(screen.queryByTestId('return-icon')).toBeInTheDocument(); - }); - }); - it('should render custom ExitSearchIcon', async () => { - const ExitSearchIcon = () =>
CustomExitSearchIcon
; - await renderComponent({ - client, - props: { ExitSearchIcon }, - searchParams: { disabled: false }, - }); - - const input = screen.queryByTestId('search-input'); - - await act(() => { - fireEvent.focus(input); - }); - - await waitFor(() => { - expect(screen.queryByText('CustomExitSearchIcon')).toBeInTheDocument(); - }); - }); - it('should render custom input placeholder', async () => { - const placeholder = 'Type and search xxxx'; - await act(() => { - renderComponent({ - client, - props: { placeholder }, - searchParams: { disabled: false }, - }); - }); - - await waitFor(() => { - expect(screen.queryByPlaceholderText(placeholder)).toBeInTheDocument(); - }); - }); - it('should clear input', async () => { - renderComponent({ client, searchParams: { disabled: false } }); - - const input = screen.queryByTestId('search-input'); - - await act(() => { - input.focus(); - fireEvent.change(input, { - target: { - value: inputText, - }, - }); - }); - - await waitFor(() => { - expect(input).toHaveValue(inputText); - }); - - const clearButton = screen.queryByTestId('clear-input-button'); - - await act(() => { - fireEvent.click(clearButton); - }); - - await waitFor(() => { - expect(input).toHaveValue(''); - expect(input).toHaveFocus(); - expect(screen.queryByTestId('return-icon')).toBeInTheDocument(); - }); - }); - - it.each([ - [ - 'on return button click', - (target) => { - fireEvent.click(target); - }, - ], - [ - 'on Escape key down', - (target) => { - fireEvent.keyDown(target, { key: 'Escape' }); - }, - ], - ])('should exit search UI %s', async (_case, doExitAction) => { - await renderComponent({ client, searchParams: { disabled: false } }); - - const input = screen.queryByTestId('search-input'); - - await act(() => { - input.focus(); - fireEvent.change(input, { - target: { - value: inputText, - }, - }); - }); - - await waitFor(() => { - expect(input).toHaveValue(inputText); - }); - - const returnButton = screen.queryByTestId('search-bar-button'); - - await act(() => { - const target = _case === 'on return button click' ? returnButton : input; - doExitAction(target); - }); - - await waitFor(() => { - expect(input).toHaveValue(''); - expect(input).not.toHaveFocus(); - expect(screen.queryByTestId('return-icon')).not.toBeInTheDocument(); - }); - }); - - it('should render custom SearchInput', async () => { - const SearchInput = () =>
CustomSearchInput
; - await act(() => { - renderComponent({ - client, - props: { SearchInput }, - searchParams: { disabled: false }, - }); - }); - - await waitFor(() => { - expect(screen.queryByText('CustomSearchInput')).toBeInTheDocument(); - expect(screen.queryByTestId('search-input')).not.toBeInTheDocument(); - }); - }); - - it('should toggle app menu render with menu icon click', async () => { - await act(() => { - renderComponent({ - client, - props: { AppMenu }, - searchParams: { disabled: false }, - }); - }); - const menuIcon = screen.queryByTestId('menu-icon'); - await act(() => { - fireEvent.click(menuIcon); - }); - await waitFor(() => { - expect(screen.queryByText('AppMenu')).toBeInTheDocument(); - }); - await act(() => { - fireEvent.click(menuIcon); - }); - await waitFor(() => { - expect(screen.queryByText('AppMenu')).not.toBeInTheDocument(); - }); - }); - - it('should close the app menu on menu item click', async () => { - await act(() => { - renderComponent({ - client, - props: { AppMenu }, - searchParams: { disabled: false }, - }); - }); - const menuIcon = screen.queryByTestId('menu-icon'); - await act(() => { - fireEvent.click(menuIcon); - }); - - const menuItem = screen.queryByTestId('menu-item'); - - await act(() => { - fireEvent.click(menuItem); - }); - - await waitFor(() => { - expect(screen.queryByText('AppMenu')).not.toBeInTheDocument(); - }); - }); - - it.each([ - [ - 'on click outside', - (target) => { - fireEvent.click(target); - }, - ], - [ - 'on Escape key down', - (target) => { - fireEvent.keyDown(target, { key: 'Escape' }); - }, - ], - ])('should close app menu %s', async (_, doCloseAction) => { - await act(() => { - renderComponent({ - client, - props: { AppMenu }, - searchParams: { disabled: false }, - }); - }); - const menuIcon = screen.queryByTestId('menu-icon'); - const searchBar = screen.queryByTestId('search-bar'); - await act(() => { - fireEvent.click(menuIcon); - }); - await waitFor(() => { - expect(screen.queryByText('AppMenu')).toBeInTheDocument(); - }); - - await act(() => { - doCloseAction(searchBar); - }); - - await waitFor(() => { - expect(screen.queryByText('AppMenu')).not.toBeInTheDocument(); - }); - }); -}); diff --git a/src/components/ChannelSearch/__tests__/SearchResults.test.js b/src/components/ChannelSearch/__tests__/SearchResults.test.js deleted file mode 100644 index 85152b8e0..000000000 --- a/src/components/ChannelSearch/__tests__/SearchResults.test.js +++ /dev/null @@ -1,119 +0,0 @@ -import React from 'react'; -import { act, fireEvent, render, screen } from '@testing-library/react'; -import '@testing-library/jest-dom'; - -import { SearchResults } from '../SearchResults'; - -import { ChatProvider } from '../../../context/ChatContext'; - -import { - createClientWithChannel, - generateChannel, - generateUser, -} from '../../../mock-builders'; - -const SEARCH_RESULT_LIST_SELECTOR = '.str-chat__channel-search-result-list'; - -const renderComponent = (props = {}, chatContext = {}) => - render( - - - , - ); - -describe('SearchResults', () => { - it('should render loading indicator', () => { - renderComponent({ searching: true }); - expect(screen.queryByTestId('search-in-progress-indicator')).toBeInTheDocument(); - }); - - it('should not render loading indicator if search not in progress', () => { - renderComponent({ results: [] }); - expect(screen.queryByTestId('search-in-progress-indicator')).not.toBeInTheDocument(); - }); - - it('should render custom loading indicator if search in progress', () => { - const SearchLoading = () =>
CustomSearchLoading
; - renderComponent({ searching: true, SearchLoading }); - expect(screen.queryByTestId('search-in-progress-indicator')).not.toBeInTheDocument(); - expect(screen.queryByText('CustomSearchLoading')).toBeInTheDocument(); - }); - - it('should not render custom loading indicator if search not in progress', () => { - const SearchLoading = () =>
CustomSearchLoading
; - renderComponent({ results: [], SearchLoading }); - expect(screen.queryByTestId('search-in-progress-indicator')).not.toBeInTheDocument(); - expect(screen.queryByText('CustomSearchLoading')).not.toBeInTheDocument(); - }); - - it('should render empty search result indicator', () => { - renderComponent({ results: [] }); - expect(screen.queryByText('No results found')).toBeInTheDocument(); - }); - - it('should render custom empty search result indicator', () => { - const SearchEmpty = () =>
CustomSearchEmpty
; - renderComponent({ results: [], SearchEmpty }); - expect(screen.queryByText('No results found')).not.toBeInTheDocument(); - expect(screen.queryByText('CustomSearchEmpty')).toBeInTheDocument(); - }); - it('should render search results header', () => { - renderComponent({ results: [generateChannel()] }); - expect(screen.queryByTestId('channel-search-results-header')).toBeInTheDocument(); - }); - it('should render custom search results header', () => { - const SearchResultsHeader = () =>
CustomSearchResultsHeader
; - renderComponent({ results: [generateChannel()], SearchResultsHeader }); - expect(screen.queryByText('CustomSearchResultsHeader')).toBeInTheDocument(); - }); - it(`should render channel search result`, async () => { - const { channel, client } = await createClientWithChannel(); - renderComponent({ results: [channel] }, { client }); - expect(screen.queryByTestId('channel-preview-button')).toBeInTheDocument(); - expect(screen.queryByTestId('channel-search-result-channel')).not.toBeInTheDocument(); - }); - it(`should render non-channel search result`, async () => { - const user = generateUser(); - const { client } = await createClientWithChannel(); - renderComponent({ results: [user] }, { client }); - expect(screen.queryByTestId('channel-search-result-user')).toBeInTheDocument(); - }); - it('should render custom search results list', () => { - const SearchResultsList = () =>
CustomSearchResultsList
; - renderComponent({ results: [generateChannel()], SearchResultsList }); - expect(screen.queryByText('CustomSearchResultsList')).toBeInTheDocument(); - }); - it('should render custom search results items', () => { - const SearchResultItem = () =>
CustomSearchResultItem
; - renderComponent({ results: [generateChannel()], SearchResultItem }); - expect(screen.queryByText('CustomSearchResultItem')).toBeInTheDocument(); - }); - - it('should allow to navigate results with arrow keys', async () => { - const { channel, client } = await createClientWithChannel(); - const { container } = renderComponent({ results: [channel] }, { client }); - const searchResultList = container.querySelector(SEARCH_RESULT_LIST_SELECTOR); - searchResultList.focus(); - await act(() => { - fireEvent.keyDown(searchResultList, { key: 'ArrowDown' }); - }); - await act(() => { - fireEvent.keyDown(searchResultList, { key: 'ArrowDown' }); - }); - expect(searchResultList.children[1].lastChild).toHaveClass( - 'str-chat__channel-search-result--focused', - ); - }); - - it('should add class "inline" to the results list root by default', () => { - const { container } = renderComponent({ results: [] }); - const searchResultList = container.querySelector(SEARCH_RESULT_LIST_SELECTOR); - expect(searchResultList).toHaveClass('inline'); - }); - - it('should add popup class to the results list root', () => { - const { container } = renderComponent({ popupResults: true, results: [] }); - const searchResultList = container.querySelector(SEARCH_RESULT_LIST_SELECTOR); - expect(searchResultList).toHaveClass('popup'); - }); -}); diff --git a/src/components/ChannelSearch/__tests__/__snapshots__/ChannelSearch.test.js.snap b/src/components/ChannelSearch/__tests__/__snapshots__/ChannelSearch.test.js.snap deleted file mode 100644 index 556f611f6..000000000 --- a/src/components/ChannelSearch/__tests__/__snapshots__/ChannelSearch.test.js.snap +++ /dev/null @@ -1,117 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`ChannelSearch displays custom placeholder 1`] = ` - -`; - -exports[`ChannelSearch should render component without any props 1`] = ` - -`; diff --git a/src/components/ChannelSearch/__tests__/__snapshots__/SearchBar.test.js.snap b/src/components/ChannelSearch/__tests__/__snapshots__/SearchBar.test.js.snap deleted file mode 100644 index 0357da5ff..000000000 --- a/src/components/ChannelSearch/__tests__/__snapshots__/SearchBar.test.js.snap +++ /dev/null @@ -1,56 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`SearchBar should render default layout 1`] = ` -
- -
-`; diff --git a/src/components/ChannelSearch/hooks/useChannelSearch.ts b/src/components/ChannelSearch/hooks/useChannelSearch.ts deleted file mode 100644 index 2e47bfa35..000000000 --- a/src/components/ChannelSearch/hooks/useChannelSearch.ts +++ /dev/null @@ -1,327 +0,0 @@ -import type React from 'react'; -import { useCallback, useEffect, useRef, useState } from 'react'; -import debounce from 'lodash.debounce'; -import uniqBy from 'lodash.uniqby'; - -import type { ChannelOrUserResponse } from '../utils'; -import { isChannel } from '../utils'; - -import { useChatContext } from '../../../context/ChatContext'; - -import type { - Channel, - ChannelFilters, - ChannelOptions, - ChannelSort, - UserFilters, - UserOptions, - UsersAPIResponse, - UserSort, -} from 'stream-chat'; -import type { SearchBarController } from '../SearchBar'; -import type { SearchInputController } from '../SearchInput'; -import type { SearchResultsController } from '../SearchResults'; - -export type ChannelSearchFunctionParams = { - setQuery: React.Dispatch>; - setResults: React.Dispatch>; - setSearching: React.Dispatch>; -}; - -export type SearchController = SearchInputController & - SearchBarController & - SearchResultsController; - -export type SearchQueryParams = { - channelFilters?: { - filters?: ChannelFilters; - options?: ChannelOptions; - sort?: ChannelSort; - }; - userFilters?: { - filters?: UserFilters | ((query: string) => UserFilters); - options?: UserOptions; - sort?: UserSort; - }; -}; - -export type ChannelSearchParams = { - /** The type of channel to create on user result select, defaults to `messaging` */ - channelType?: string; - /** Clear search state / results on every click outside the search input, defaults to true */ - clearSearchOnClickOutside?: boolean; - /** Disables execution of the search queries, defaults to false */ - disabled?: boolean; - /** Callback invoked with every search input change handler */ - onSearch?: SearchInputController['onSearch']; - /** Callback invoked when the search UI is deactivated */ - onSearchExit?: () => void; - /** Custom handler function to run on search result item selection */ - onSelectResult?: ( - params: ChannelSearchFunctionParams, - result: ChannelOrUserResponse, - ) => Promise | void; - /** The number of milliseconds to debounce the search query. The default interval is 300ms. */ - searchDebounceIntervalMs?: number; - /** Boolean to search for channels in the server query, default is false and just searches for users */ - searchForChannels?: boolean; - /** Boolean to search for users in the server query, default is true and just searches for users */ - searchForUsers?: boolean; - /** Custom search function to override the default implementation */ - searchFunction?: ( - params: ChannelSearchFunctionParams, - event: React.BaseSyntheticEvent, - ) => Promise | void; - /** Object containing filters/sort/options overrides for user / channel search */ - searchQueryParams?: SearchQueryParams; -}; - -export type ChannelSearchControllerParams = ChannelSearchParams & { - /** Set the array of channels displayed in the ChannelList */ - setChannels?: React.Dispatch>>; -}; - -export const useChannelSearch = ({ - channelType = 'messaging', - clearSearchOnClickOutside = true, - disabled = false, - onSearch: onSearchCallback, - onSearchExit, - onSelectResult, - searchDebounceIntervalMs = 300, - searchForChannels = false, - searchForUsers = true, - searchFunction, - searchQueryParams, - setChannels, -}: ChannelSearchControllerParams): SearchController => { - const { client, setActiveChannel } = useChatContext('useChannelSearch'); - - const [inputIsFocused, setInputIsFocused] = useState(false); - const [query, setQuery] = useState(''); - const [results, setResults] = useState>([]); - const [searching, setSearching] = useState(false); - - const searchQueryPromiseInProgress = useRef(false); - const shouldIgnoreQueryResults = useRef(false); - - const inputRef = useRef(null); - const searchBarRef = useRef(null); - - const clearState = useCallback(() => { - setQuery(''); - setResults([]); - setSearching(false); - - shouldIgnoreQueryResults.current = searchQueryPromiseInProgress.current; - }, []); - - const activateSearch = useCallback(() => { - setInputIsFocused(true); - }, []); - - const exitSearch = useCallback(() => { - setInputIsFocused(false); - inputRef.current?.blur(); - clearState(); - onSearchExit?.(); - }, [clearState, onSearchExit]); - - useEffect(() => { - if (disabled) return; - - const clickListener = (event: MouseEvent) => { - if (!(event.target instanceof HTMLElement)) return; - const isInputClick = searchBarRef.current?.contains(event.target); - - if (isInputClick) return; - - if ((inputIsFocused && !query) || clearSearchOnClickOutside) { - exitSearch(); - } - }; - - document.addEventListener('click', clickListener); - return () => document.removeEventListener('click', clickListener); - }, [disabled, inputIsFocused, query, exitSearch, clearSearchOnClickOutside]); - - useEffect(() => { - if (!inputRef.current || disabled) return; - - const handleKeyDown = (event: KeyboardEvent) => { - if (event.key === 'Escape') return exitSearch(); - }; - inputRef.current.addEventListener('keydown', handleKeyDown); - - return () => { - // eslint-disable-next-line react-hooks/exhaustive-deps - inputRef.current?.removeEventListener('keydown', handleKeyDown); - }; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [disabled]); - - const selectResult = useCallback( - async (result: ChannelOrUserResponse) => { - if (!client.userID) return; - if (onSelectResult) { - await onSelectResult( - { - setQuery, - setResults, - setSearching, - }, - result, - ); - return; - } - let selectedChannel: Channel; - if (isChannel(result)) { - setActiveChannel(result); - selectedChannel = result; - } else { - const newChannel = client.channel(channelType, { - members: [client.userID, result.id], - }); - await newChannel.watch(); - - setActiveChannel(newChannel); - selectedChannel = newChannel; - } - setChannels?.((channels) => uniqBy([selectedChannel, ...channels], 'cid')); - if (clearSearchOnClickOutside) { - exitSearch(); - } - }, - // eslint-disable-next-line react-hooks/exhaustive-deps - [ - clearSearchOnClickOutside, - client, - exitSearch, - onSelectResult, - setActiveChannel, - setChannels, - ], - ); - - const getChannels = useCallback( - async (text: string) => { - if (!searchForChannels && !searchForUsers) return; - let results: ChannelOrUserResponse[] = []; - const promises: Array | Promise> = []; - try { - if (searchForChannels) { - promises.push( - client.queryChannels( - { - members: { $in: [client.userID as string] }, - name: { $autocomplete: text }, - ...searchQueryParams?.channelFilters?.filters, - }, - searchQueryParams?.channelFilters?.sort || {}, - { limit: 5, ...searchQueryParams?.channelFilters?.options }, - ), - ); - } - - if (searchForUsers) { - promises.push( - client.queryUsers( - { - $or: [{ id: { $autocomplete: text } }, { name: { $autocomplete: text } }], - ...searchQueryParams?.userFilters?.filters, - }, - { id: 1, ...searchQueryParams?.userFilters?.sort }, - { limit: 8, ...searchQueryParams?.userFilters?.options }, - ), - ); - } - - if (promises.length) { - searchQueryPromiseInProgress.current = true; - - const resolved = await Promise.all(promises); - - if (searchForChannels && searchForUsers) { - const [channels, { users }] = resolved as [Channel[], UsersAPIResponse]; - results = [...channels, ...users.filter((u) => u.id !== client.user?.id)]; - } else if (searchForChannels) { - const [channels] = resolved as [Channel[]]; - results = [...channels]; - } else if (searchForUsers) { - const [{ users }] = resolved as [UsersAPIResponse]; - results = [...users.filter((u) => u.id !== client.user?.id)]; - } - } - } catch (error) { - console.error(error); - } - setSearching(false); - - if (!shouldIgnoreQueryResults.current) { - setResults(results); - } else { - shouldIgnoreQueryResults.current = false; - } - - searchQueryPromiseInProgress.current = false; - }, - [client, searchForChannels, searchForUsers, searchQueryParams], - ); - - // eslint-disable-next-line react-hooks/exhaustive-deps - const scheduleGetChannels = useCallback( - debounce(getChannels, searchDebounceIntervalMs), - [getChannels, searchDebounceIntervalMs], - ); - - const onSearch = useCallback( - (event: React.ChangeEvent) => { - event.preventDefault(); - if (disabled) return; - - if (searchFunction) { - searchFunction( - { - setQuery, - setResults, - setSearching, - }, - event, - ); - } else if (!searchForChannels && !searchForUsers) { - return; - } else if (event.target.value) { - setSearching(true); - setQuery(event.target.value); - scheduleGetChannels(event.target.value); - } else if (!event.target.value) { - clearState(); - scheduleGetChannels.cancel(); - } - onSearchCallback?.(event); - }, - [ - clearState, - disabled, - scheduleGetChannels, - onSearchCallback, - searchForChannels, - searchForUsers, - searchFunction, - ], - ); - - return { - activateSearch, - clearState, - exitSearch, - inputIsFocused, - inputRef, - onSearch, - query, - results, - searchBarRef, - searching, - selectResult, - }; -}; diff --git a/src/components/ChannelSearch/icons.tsx b/src/components/ChannelSearch/icons.tsx deleted file mode 100644 index d841b79ef..000000000 --- a/src/components/ChannelSearch/icons.tsx +++ /dev/null @@ -1,67 +0,0 @@ -import React from 'react'; -import type { IconProps } from '../../types/types'; - -export const MenuIcon = () => ( - - - -); - -export const ReturnIcon = () => ( - - - -); - -export const XIcon = () => ( - - - -); - -export const SearchIcon = ({ className }: IconProps) => ( - - - -); diff --git a/src/components/ChannelSearch/index.ts b/src/components/ChannelSearch/index.ts deleted file mode 100644 index 716d76b7a..000000000 --- a/src/components/ChannelSearch/index.ts +++ /dev/null @@ -1,9 +0,0 @@ -export * from './ChannelSearch'; -export * from './SearchBar'; -export * from './SearchInput'; -export * from './SearchResults'; -export * from './utils'; -export type { - ChannelSearchFunctionParams, - ChannelSearchParams, -} from './hooks/useChannelSearch'; diff --git a/src/components/ChannelSearch/utils.ts b/src/components/ChannelSearch/utils.ts deleted file mode 100644 index b98b8db3e..000000000 --- a/src/components/ChannelSearch/utils.ts +++ /dev/null @@ -1,6 +0,0 @@ -import type { Channel, UserResponse } from 'stream-chat'; - -export type ChannelOrUserResponse = Channel | UserResponse; - -export const isChannel = (output: ChannelOrUserResponse): output is Channel => - (output as Channel).cid != null; diff --git a/src/experimental/Search/Search.tsx b/src/components/Search/Search.tsx similarity index 100% rename from src/experimental/Search/Search.tsx rename to src/components/Search/Search.tsx diff --git a/src/experimental/Search/SearchBar/SearchBar.tsx b/src/components/Search/SearchBar/SearchBar.tsx similarity index 100% rename from src/experimental/Search/SearchBar/SearchBar.tsx rename to src/components/Search/SearchBar/SearchBar.tsx diff --git a/src/experimental/Search/SearchBar/index.ts b/src/components/Search/SearchBar/index.ts similarity index 100% rename from src/experimental/Search/SearchBar/index.ts rename to src/components/Search/SearchBar/index.ts diff --git a/src/experimental/Search/SearchContext.tsx b/src/components/Search/SearchContext.tsx similarity index 100% rename from src/experimental/Search/SearchContext.tsx rename to src/components/Search/SearchContext.tsx diff --git a/src/experimental/Search/SearchResults/SearchResultItem.tsx b/src/components/Search/SearchResults/SearchResultItem.tsx similarity index 100% rename from src/experimental/Search/SearchResults/SearchResultItem.tsx rename to src/components/Search/SearchResults/SearchResultItem.tsx diff --git a/src/experimental/Search/SearchResults/SearchResults.tsx b/src/components/Search/SearchResults/SearchResults.tsx similarity index 100% rename from src/experimental/Search/SearchResults/SearchResults.tsx rename to src/components/Search/SearchResults/SearchResults.tsx diff --git a/src/experimental/Search/SearchResults/SearchResultsHeader.tsx b/src/components/Search/SearchResults/SearchResultsHeader.tsx similarity index 100% rename from src/experimental/Search/SearchResults/SearchResultsHeader.tsx rename to src/components/Search/SearchResults/SearchResultsHeader.tsx diff --git a/src/experimental/Search/SearchResults/SearchResultsPresearch.tsx b/src/components/Search/SearchResults/SearchResultsPresearch.tsx similarity index 100% rename from src/experimental/Search/SearchResults/SearchResultsPresearch.tsx rename to src/components/Search/SearchResults/SearchResultsPresearch.tsx diff --git a/src/experimental/Search/SearchResults/SearchSourceResultList.tsx b/src/components/Search/SearchResults/SearchSourceResultList.tsx similarity index 100% rename from src/experimental/Search/SearchResults/SearchSourceResultList.tsx rename to src/components/Search/SearchResults/SearchSourceResultList.tsx diff --git a/src/experimental/Search/SearchResults/SearchSourceResultListFooter.tsx b/src/components/Search/SearchResults/SearchSourceResultListFooter.tsx similarity index 100% rename from src/experimental/Search/SearchResults/SearchSourceResultListFooter.tsx rename to src/components/Search/SearchResults/SearchSourceResultListFooter.tsx diff --git a/src/experimental/Search/SearchResults/SearchSourceResults.tsx b/src/components/Search/SearchResults/SearchSourceResults.tsx similarity index 100% rename from src/experimental/Search/SearchResults/SearchSourceResults.tsx rename to src/components/Search/SearchResults/SearchSourceResults.tsx diff --git a/src/experimental/Search/SearchResults/SearchSourceResultsEmpty.tsx b/src/components/Search/SearchResults/SearchSourceResultsEmpty.tsx similarity index 100% rename from src/experimental/Search/SearchResults/SearchSourceResultsEmpty.tsx rename to src/components/Search/SearchResults/SearchSourceResultsEmpty.tsx diff --git a/src/experimental/Search/SearchResults/SearchSourceResultsHeader.tsx b/src/components/Search/SearchResults/SearchSourceResultsHeader.tsx similarity index 100% rename from src/experimental/Search/SearchResults/SearchSourceResultsHeader.tsx rename to src/components/Search/SearchResults/SearchSourceResultsHeader.tsx diff --git a/src/experimental/Search/SearchResults/SearchSourceResultsLoadingIndicator.tsx b/src/components/Search/SearchResults/SearchSourceResultsLoadingIndicator.tsx similarity index 100% rename from src/experimental/Search/SearchResults/SearchSourceResultsLoadingIndicator.tsx rename to src/components/Search/SearchResults/SearchSourceResultsLoadingIndicator.tsx diff --git a/src/experimental/Search/SearchResults/index.ts b/src/components/Search/SearchResults/index.ts similarity index 100% rename from src/experimental/Search/SearchResults/index.ts rename to src/components/Search/SearchResults/index.ts diff --git a/src/experimental/Search/SearchSourceResultsContext.tsx b/src/components/Search/SearchSourceResultsContext.tsx similarity index 100% rename from src/experimental/Search/SearchSourceResultsContext.tsx rename to src/components/Search/SearchSourceResultsContext.tsx diff --git a/src/experimental/Search/__tests__/Search.test.js b/src/components/Search/__tests__/Search.test.js similarity index 100% rename from src/experimental/Search/__tests__/Search.test.js rename to src/components/Search/__tests__/Search.test.js diff --git a/src/experimental/Search/__tests__/SearchBar.test.js b/src/components/Search/__tests__/SearchBar.test.js similarity index 100% rename from src/experimental/Search/__tests__/SearchBar.test.js rename to src/components/Search/__tests__/SearchBar.test.js diff --git a/src/experimental/Search/__tests__/SearchResultItem.test.js b/src/components/Search/__tests__/SearchResultItem.test.js similarity index 100% rename from src/experimental/Search/__tests__/SearchResultItem.test.js rename to src/components/Search/__tests__/SearchResultItem.test.js diff --git a/src/experimental/Search/__tests__/SearchResults.test.js b/src/components/Search/__tests__/SearchResults.test.js similarity index 100% rename from src/experimental/Search/__tests__/SearchResults.test.js rename to src/components/Search/__tests__/SearchResults.test.js diff --git a/src/experimental/Search/__tests__/SearchResultsHeader.test.js b/src/components/Search/__tests__/SearchResultsHeader.test.js similarity index 100% rename from src/experimental/Search/__tests__/SearchResultsHeader.test.js rename to src/components/Search/__tests__/SearchResultsHeader.test.js diff --git a/src/experimental/Search/__tests__/SearchSourceResultList.test.js b/src/components/Search/__tests__/SearchSourceResultList.test.js similarity index 100% rename from src/experimental/Search/__tests__/SearchSourceResultList.test.js rename to src/components/Search/__tests__/SearchSourceResultList.test.js diff --git a/src/experimental/Search/__tests__/SearchSourceResultListFooter.test.js b/src/components/Search/__tests__/SearchSourceResultListFooter.test.js similarity index 100% rename from src/experimental/Search/__tests__/SearchSourceResultListFooter.test.js rename to src/components/Search/__tests__/SearchSourceResultListFooter.test.js diff --git a/src/experimental/Search/__tests__/SearchSourceResults.test.js b/src/components/Search/__tests__/SearchSourceResults.test.js similarity index 100% rename from src/experimental/Search/__tests__/SearchSourceResults.test.js rename to src/components/Search/__tests__/SearchSourceResults.test.js diff --git a/src/experimental/Search/hooks/index.ts b/src/components/Search/hooks/index.ts similarity index 100% rename from src/experimental/Search/hooks/index.ts rename to src/components/Search/hooks/index.ts diff --git a/src/experimental/Search/hooks/useSearchFocusedMessage.ts b/src/components/Search/hooks/useSearchFocusedMessage.ts similarity index 100% rename from src/experimental/Search/hooks/useSearchFocusedMessage.ts rename to src/components/Search/hooks/useSearchFocusedMessage.ts diff --git a/src/experimental/Search/hooks/useSearchQueriesInProgress.ts b/src/components/Search/hooks/useSearchQueriesInProgress.ts similarity index 100% rename from src/experimental/Search/hooks/useSearchQueriesInProgress.ts rename to src/components/Search/hooks/useSearchQueriesInProgress.ts diff --git a/src/experimental/Search/index.ts b/src/components/Search/index.ts similarity index 100% rename from src/experimental/Search/index.ts rename to src/components/Search/index.ts diff --git a/src/experimental/Search/styling/Search.scss b/src/components/Search/styling/Search.scss similarity index 100% rename from src/experimental/Search/styling/Search.scss rename to src/components/Search/styling/Search.scss diff --git a/src/experimental/Search/styling/index.scss b/src/components/Search/styling/index.scss similarity index 100% rename from src/experimental/Search/styling/index.scss rename to src/components/Search/styling/index.scss diff --git a/src/components/Thread/styling/ThreadHead.scss b/src/components/Thread/styling/ThreadHead.scss index 5196181d0..0f69a282f 100644 --- a/src/components/Thread/styling/ThreadHead.scss +++ b/src/components/Thread/styling/ThreadHead.scss @@ -4,7 +4,7 @@ .str-chat__message { max-width: calc( var(--str-chat__message-composer-max-width) + - var(--str-chat__message-composer-padding) + var(--str-chat__message-composer-padding) ); padding-block: var(--spacing-xs); margin-inline: auto; diff --git a/src/components/TypingIndicator/styling/TypingIndicator.scss b/src/components/TypingIndicator/styling/TypingIndicator.scss index 436d71c96..08ff60f59 100644 --- a/src/components/TypingIndicator/styling/TypingIndicator.scss +++ b/src/components/TypingIndicator/styling/TypingIndicator.scss @@ -18,7 +18,10 @@ display: flex; align-items: flex-end; width: 100%; - max-width: calc(var(--str-chat__message-composer-max-width) + var(--str-chat__message-composer-padding)); + max-width: calc( + var(--str-chat__message-composer-max-width) + + var(--str-chat__message-composer-padding) + ); margin: auto; gap: var(--spacing-xs); padding-block: var(--spacing-xs); diff --git a/src/components/index.ts b/src/components/index.ts index 59e71bd5f..69821d23f 100644 --- a/src/components/index.ts +++ b/src/components/index.ts @@ -8,7 +8,7 @@ export * from './Channel'; export * from './ChannelHeader'; export * from './ChannelList'; export * from './ChannelPreview'; -export * from './ChannelSearch'; +export * from './Search'; export * from './Chat'; export * from './ChatView'; export * from './DateSeparator'; diff --git a/src/context/ComponentContext.tsx b/src/context/ComponentContext.tsx index 6b765a72d..daa8444e5 100644 --- a/src/context/ComponentContext.tsx +++ b/src/context/ComponentContext.tsx @@ -40,7 +40,10 @@ import { type ReactionsListProps, type RecordingPermissionDeniedNotificationProps, type ReminderNotificationProps, + type SearchResultsPresearchProps, + type SearchSourceResultListProps, type SendButtonProps, + type ShareLocationDialogProps, type StartRecordingAudioButtonProps, type StreamedMessageTextProps, type TextareaComposerProps, @@ -60,15 +63,8 @@ import type { SuggestionListProps, } from '../components/TextareaComposer'; -import type { - SearchProps, - SearchResultsPresearchProps, - SearchSourceResultListProps, -} from '../experimental'; - import type { PropsWithChildrenOnly } from '../types/types'; import type { StopAIGenerationButtonProps } from '../components/MessageInput/StopAIGenerationButton'; -import type { ShareLocationDialogProps } from '../components/Location'; import type { VideoPlayerProps } from '../components/VideoPlayer'; import type { EditedMessagePreviewProps } from '../components/MessageInput/EditedMessagePreview'; @@ -192,7 +188,7 @@ export type ComponentContextValue = { /** Custom UI component to display the message translation indicator when message has i18n, defaults to and accepts same props as: [MessageTranslationIndicator](https://github.com/GetStream/stream-chat-react/blob/master/src/components/Message/TranslationIndicator.tsx) */ MessageTranslationIndicator?: React.ComponentType; /** Custom component to display the search UI, defaults to and accepts same props as: [Search](https://github.com/GetStream/stream-chat-react/blob/master/src/components/Search/Search.tsx) */ - Search?: React.ComponentType; + Search?: React.ComponentType; /** Custom component to display the UI where the searched string is entered, defaults to and accepts same props as: [SearchBar](https://github.com/GetStream/stream-chat-react/blob/master/src/components/Search/SearchBar/SearchBar.tsx) */ SearchBar?: React.ComponentType; /** Custom component for the search UI dedicated to display the results area, defaults to and accepts same props as: [SearchResults](https://github.com/GetStream/stream-chat-react/blob/master/src/components/Search/SearchResults/SearchResults.tsx) */ diff --git a/src/experimental/index.ts b/src/experimental/index.ts deleted file mode 100644 index addd53308..000000000 --- a/src/experimental/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './Search'; diff --git a/src/styling/index.scss b/src/styling/index.scss index 111cb67a8..8b195a4fb 100644 --- a/src/styling/index.scss +++ b/src/styling/index.scss @@ -45,7 +45,7 @@ @use '../components/MessageList/styling' as MessageList; @use '../components/Poll/styling' as Poll; @use '../components/Reactions/styling' as Reactions; -@use '../experimental/Search/styling' as Search; +@use '../components/Search/styling' as Search; @use '../components/SummarizedMessagePreview/styling' as SummarizedMessagePreview; @use '../components/TextareaComposer/styling' as TextareaComposer; @use '../components/Thread/styling' as Thread; diff --git a/vite.config.ts b/vite.config.ts index 8fa631b12..d57d99b3e 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -18,7 +18,6 @@ export default defineConfig({ index: resolve(__dirname, './src/index.ts'), emojis: resolve(__dirname, './src/plugins/Emojis/index.ts'), 'mp3-encoder': resolve(__dirname, './src/plugins/encoders/mp3.ts'), - experimental: resolve(__dirname, './src/experimental/index.ts'), }, fileName(format, entryName) { return `${format}/${entryName}.${format === 'cjs' ? 'js' : 'mjs'}`;