From bdecaa99306700d7385b2e2deb785ca5a1c51d92 Mon Sep 17 00:00:00 2001 From: Bill Thornton Date: Tue, 13 Feb 2024 01:09:08 -0500 Subject: [PATCH] Fix issues with search url param --- package-lock.json | 29 +++- package.json | 1 + src/apps/stable/routes/search.tsx | 59 +++---- src/components/search/LiveTVSearchResults.tsx | 156 +++++++++--------- src/components/search/SearchFields.tsx | 96 ++++------- src/components/search/SearchResults.tsx | 28 ++-- src/elements/emby-input/Input.tsx | 59 +++++++ src/hooks/usePrevious.ts | 17 ++ 8 files changed, 256 insertions(+), 189 deletions(-) create mode 100644 src/elements/emby-input/Input.tsx create mode 100644 src/hooks/usePrevious.ts diff --git a/package-lock.json b/package-lock.json index cf80543d0a..c371ea7149 100644 --- a/package-lock.json +++ b/package-lock.json @@ -58,6 +58,7 @@ "screenfull": "6.0.2", "sortablejs": "1.15.2", "swiper": "11.0.5", + "usehooks-ts": "2.14.0", "webcomponents.js": "0.7.24", "whatwg-fetch": "3.6.20" }, @@ -12671,8 +12672,7 @@ "node_modules/lodash.debounce": { "version": "4.0.8", "resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz", - "integrity": "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==", - "dev": true + "integrity": "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==" }, "node_modules/lodash.memoize": { "version": "4.1.2", @@ -21900,6 +21900,20 @@ "react": "^16.8.0 || ^17.0.0 || ^18.0.0" } }, + "node_modules/usehooks-ts": { + "version": "2.14.0", + "resolved": "https://registry.npmjs.org/usehooks-ts/-/usehooks-ts-2.14.0.tgz", + "integrity": "sha512-jnhrjTRJoJS7cFxz63tRYc5mzTKf/h+Ii8P0PDHymT9qDe4ZA2/gzDRmDR4WGausg5X8wMIdghwi3BBCN9JKow==", + "dependencies": { + "lodash.debounce": "^4.0.8" + }, + "engines": { + "node": ">=16.15.0" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17 || ^18" + } + }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", @@ -31866,8 +31880,7 @@ "lodash.debounce": { "version": "4.0.8", "resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz", - "integrity": "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==", - "dev": true + "integrity": "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==" }, "lodash.memoize": { "version": "4.1.2", @@ -38668,6 +38681,14 @@ "integrity": "sha512-eEgnFxGQ1Ife9bzYs6VLi8/4X6CObHMw9Qr9tPY43iKwsPw8xE8+EFsf/2cFZ5S3esXgpWgtSCtLNS41F+sKPA==", "requires": {} }, + "usehooks-ts": { + "version": "2.14.0", + "resolved": "https://registry.npmjs.org/usehooks-ts/-/usehooks-ts-2.14.0.tgz", + "integrity": "sha512-jnhrjTRJoJS7cFxz63tRYc5mzTKf/h+Ii8P0PDHymT9qDe4ZA2/gzDRmDR4WGausg5X8wMIdghwi3BBCN9JKow==", + "requires": { + "lodash.debounce": "^4.0.8" + } + }, "util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", diff --git a/package.json b/package.json index 91b87c28f3..ee026b811d 100644 --- a/package.json +++ b/package.json @@ -119,6 +119,7 @@ "screenfull": "6.0.2", "sortablejs": "1.15.2", "swiper": "11.0.5", + "usehooks-ts": "2.14.0", "webcomponents.js": "0.7.24", "whatwg-fetch": "3.6.20" }, diff --git a/src/apps/stable/routes/search.tsx b/src/apps/stable/routes/search.tsx index 095f735852..da55e9d8e8 100644 --- a/src/apps/stable/routes/search.tsx +++ b/src/apps/stable/routes/search.tsx @@ -1,41 +1,36 @@ -import React, { FunctionComponent, useEffect, useRef, useState } from 'react'; -import { useSearchParams } from 'react-router-dom'; +import React, { type FC, useEffect, useState } from 'react'; +import { useNavigate, useSearchParams } from 'react-router-dom'; -import Page from '../../../components/Page'; -import SearchFields from '../../../components/search/SearchFields'; -import SearchResults from '../../../components/search/SearchResults'; -import SearchSuggestions from '../../../components/search/SearchSuggestions'; -import LiveTVSearchResults from '../../../components/search/LiveTVSearchResults'; -import globalize from '../../../scripts/globalize'; -import { history } from '../../../components/router/appRouter'; +import Page from 'components/Page'; +import SearchFields from 'components/search/SearchFields'; +import SearchResults from 'components/search/SearchResults'; +import SearchSuggestions from 'components/search/SearchSuggestions'; +import LiveTVSearchResults from 'components/search/LiveTVSearchResults'; +import { usePrevious } from 'hooks/usePrevious'; +import globalize from 'scripts/globalize'; -function usePrevious(value: string) { - const ref = useRef(''); - useEffect(() => { - ref.current = value; - }); - return ref.current; -} - -const Search: FunctionComponent = () => { - const [ searchParams ] = useSearchParams(); +const Search: FC = () => { + const navigate = useNavigate(); + const [ searchParams, setSearchParams ] = useSearchParams(); const urlQuery = searchParams.get('query') || ''; - const [ query, setQuery ] = useState(urlQuery); - const prevQuery = usePrevious(query); - - if (query == prevQuery && urlQuery != query) { - setQuery(urlQuery); - } + const [ query, setQuery ] = useState(urlQuery); + const prevQuery = usePrevious(query, ''); useEffect(() => { - const newSearch = query ? `?query=${query}` : ''; - if (query != prevQuery && newSearch != history.location.search) { - /* Explicitly using `window.history.pushState` instead of `history.replace` as the use of the latter - triggers a re-rendering of this component, resulting in double-execution searches. If there's a - way to use `history` without this side effect, it would likely be preferable. */ - window.history.pushState({}, '', `/#${history.location.pathname}${newSearch}`); + if (query !== prevQuery) { + if (query === '' && urlQuery !== '') { + // The query input has been cleared; navigate back to the search landing page + navigate(-1); + } else if (query !== urlQuery) { + // Update the query url param value + searchParams.set('query', query); + setSearchParams(searchParams, { replace: !!urlQuery }); + } + } else if (query !== urlQuery) { + // Update the query if the query url param has changed + setQuery(urlQuery); } - }, [query, prevQuery]); + }, [query, prevQuery, navigate, searchParams, setSearchParams, urlQuery]); return ( = ({ serverId = window.ApiClient.serverId(), parentId, collectionType, query }: LiveTVSearchResultsProps) => { +const LiveTVSearchResults: FC = ({ serverId = window.ApiClient.serverId(), parentId, collectionType, query }: LiveTVSearchResultsProps) => { const [ movies, setMovies ] = useState([]); const [ episodes, setEpisodes ] = useState([]); const [ sports, setSports ] = useState([]); @@ -38,23 +39,24 @@ const LiveTVSearchResults: FunctionComponent = ({ serv const [ news, setNews ] = useState([]); const [ programs, setPrograms ] = useState([]); const [ channels, setChannels ] = useState([]); + const [ debouncedQuery ] = useDebounceValue(query, 500); + + const getDefaultParameters = useCallback(() => ({ + ParentId: parentId, + searchTerm: debouncedQuery, + Limit: 24, + Fields: 'PrimaryImageAspectRatio,CanDelete,MediaSourceCount', + Recursive: true, + EnableTotalRecordCount: false, + ImageTypeLimit: 1, + IncludePeople: false, + IncludeMedia: false, + IncludeGenres: false, + IncludeStudios: false, + IncludeArtists: false + }), [ parentId, debouncedQuery ]); useEffect(() => { - const getDefaultParameters = () => ({ - ParentId: parentId, - searchTerm: query, - Limit: 24, - Fields: 'PrimaryImageAspectRatio,CanDelete,MediaSourceCount', - Recursive: true, - EnableTotalRecordCount: false, - ImageTypeLimit: 1, - IncludePeople: false, - IncludeMedia: false, - IncludeGenres: false, - IncludeStudios: false, - IncludeArtists: false - }); - const fetchItems = (apiClient: ApiClient, params = {}) => apiClient?.getItems( apiClient?.getCurrentUserId(), { @@ -73,65 +75,67 @@ const LiveTVSearchResults: FunctionComponent = ({ serv setPrograms([]); setChannels([]); - if (query && collectionType === CollectionType.Livetv) { - const apiClient = ServerConnections.getApiClient(serverId); - - // Movies row - fetchItems(apiClient, { - IncludeItemTypes: 'LiveTvProgram', - IsMovie: true - }) - .then(result => setMovies(result.Items || [])) - .catch(() => setMovies([])); - // Episodes row - fetchItems(apiClient, { - IncludeItemTypes: 'LiveTvProgram', - IsMovie: false, - IsSeries: true, - IsSports: false, - IsKids: false, - IsNews: false - }) - .then(result => setEpisodes(result.Items || [])) - .catch(() => setEpisodes([])); - // Sports row - fetchItems(apiClient, { - IncludeItemTypes: 'LiveTvProgram', - IsSports: true - }) - .then(result => setSports(result.Items || [])) - .catch(() => setSports([])); - // Kids row - fetchItems(apiClient, { - IncludeItemTypes: 'LiveTvProgram', - IsKids: true - }) - .then(result => setKids(result.Items || [])) - .catch(() => setKids([])); - // News row - fetchItems(apiClient, { - IncludeItemTypes: 'LiveTvProgram', - IsNews: true - }) - .then(result => setNews(result.Items || [])) - .catch(() => setNews([])); - // Programs row - fetchItems(apiClient, { - IncludeItemTypes: 'LiveTvProgram', - IsMovie: false, - IsSeries: false, - IsSports: false, - IsKids: false, - IsNews: false - }) - .then(result => setPrograms(result.Items || [])) - .catch(() => setPrograms([])); - // Channels row - fetchItems(apiClient, { IncludeItemTypes: 'TvChannel' }) - .then(result => setChannels(result.Items || [])) - .catch(() => setChannels([])); + if (!debouncedQuery || collectionType !== CollectionType.Livetv) { + return; } - }, [collectionType, parentId, query, serverId]); + + const apiClient = ServerConnections.getApiClient(serverId); + + // Movies row + fetchItems(apiClient, { + IncludeItemTypes: 'LiveTvProgram', + IsMovie: true + }) + .then(result => setMovies(result.Items || [])) + .catch(() => setMovies([])); + // Episodes row + fetchItems(apiClient, { + IncludeItemTypes: 'LiveTvProgram', + IsMovie: false, + IsSeries: true, + IsSports: false, + IsKids: false, + IsNews: false + }) + .then(result => setEpisodes(result.Items || [])) + .catch(() => setEpisodes([])); + // Sports row + fetchItems(apiClient, { + IncludeItemTypes: 'LiveTvProgram', + IsSports: true + }) + .then(result => setSports(result.Items || [])) + .catch(() => setSports([])); + // Kids row + fetchItems(apiClient, { + IncludeItemTypes: 'LiveTvProgram', + IsKids: true + }) + .then(result => setKids(result.Items || [])) + .catch(() => setKids([])); + // News row + fetchItems(apiClient, { + IncludeItemTypes: 'LiveTvProgram', + IsNews: true + }) + .then(result => setNews(result.Items || [])) + .catch(() => setNews([])); + // Programs row + fetchItems(apiClient, { + IncludeItemTypes: 'LiveTvProgram', + IsMovie: false, + IsSeries: false, + IsSports: false, + IsKids: false, + IsNews: false + }) + .then(result => setPrograms(result.Items || [])) + .catch(() => setPrograms([])); + // Channels row + fetchItems(apiClient, { IncludeItemTypes: 'TvChannel' }) + .then(result => setChannels(result.Items || [])) + .catch(() => setChannels([])); + }, [collectionType, debouncedQuery, getDefaultParameters, parentId, serverId]); return (
= ({ serv 'searchResults', 'padded-bottom-page', 'padded-top', - { 'hide': !query || collectionType !== CollectionType.Livetv } + { 'hide': !debouncedQuery || collectionType !== CollectionType.Livetv } )} > ({ - __html: `` -}); +import 'material-design-icons-iconfont'; -const normalizeInput = (value = '') => value.trim(); +import '../../styles/flexstyles.scss'; +import './searchfields.scss'; type SearchFieldsProps = { query: string, onSearch?: (query: string) => void }; -// eslint-disable-next-line @typescript-eslint/no-empty-function -const SearchFields: FunctionComponent = ({ onSearch = () => {}, query }: SearchFieldsProps) => { - const element = useRef(null); - - const getSearchInput = () => element?.current?.querySelector('.searchfields-txtSearch'); - - const debouncedOnSearch = useMemo(() => debounce(onSearch, 400), [onSearch]); - - const initSearchInput = getSearchInput(); - if (initSearchInput) { - initSearchInput.value = query; - } - - useEffect(() => { - getSearchInput()?.addEventListener('input', e => { - debouncedOnSearch(normalizeInput((e.target as HTMLInputElement).value)); - }); - getSearchInput()?.focus(); - - return () => { - debouncedOnSearch.cancel(); - }; - }, [debouncedOnSearch]); - +const SearchFields: FC = ({ + onSearch = () => { /* no-op */ }, + query +}: SearchFieldsProps) => { const onAlphaPicked = useCallback((e: Event) => { const value = (e as CustomEvent).detail.value; - const searchInput = getSearchInput(); - - if (!searchInput) { - console.error('Unexpected null reference'); - return; - } if (value === 'backspace') { - const currentValue = searchInput.value; - searchInput.value = currentValue.length ? currentValue.substring(0, currentValue.length - 1) : ''; + onSearch(query.length ? query.substring(0, query.length - 1) : ''); } else { - searchInput.value += value; + onSearch(query + value); } + }, [ onSearch, query ]); - searchInput.dispatchEvent(new CustomEvent('input', { bubbles: true })); - }, []); + const onChange = useCallback((e: ChangeEvent) => { + onSearch(e.target.value); + }, [ onSearch ]); return ( -
+
{layoutManager.tv && !browser.tv && diff --git a/src/components/search/SearchResults.tsx b/src/components/search/SearchResults.tsx index e267a4f5db..dbba7eb709 100644 --- a/src/components/search/SearchResults.tsx +++ b/src/components/search/SearchResults.tsx @@ -1,8 +1,9 @@ import type { BaseItemDto, BaseItemDtoQueryResult } from '@jellyfin/sdk/lib/generated-client'; import type { ApiClient } from 'jellyfin-apiclient'; import classNames from 'classnames'; -import React, { FunctionComponent, useCallback, useEffect, useState } from 'react'; +import React, { type FC, useCallback, useEffect, useState } from 'react'; import { CollectionType } from '@jellyfin/sdk/lib/generated-client/models/collection-type'; +import { useDebounceValue } from 'usehooks-ts'; import globalize from '../../scripts/globalize'; import ServerConnections from '../ServerConnections'; @@ -30,7 +31,7 @@ const isTVShows = (collectionType: string) => collectionType === CollectionType. /* * React component to display search result rows for global search and non-live tv library search */ -const SearchResults: FunctionComponent = ({ serverId = window.ApiClient.serverId(), parentId, collectionType, query }: SearchResultsProps) => { +const SearchResults: FC = ({ serverId = window.ApiClient.serverId(), parentId, collectionType, query }: SearchResultsProps) => { const [ movies, setMovies ] = useState([]); const [ shows, setShows ] = useState([]); const [ episodes, setEpisodes ] = useState([]); @@ -47,11 +48,12 @@ const SearchResults: FunctionComponent = ({ serverId = windo const [ books, setBooks ] = useState([]); const [ people, setPeople ] = useState([]); const [ collections, setCollections ] = useState([]); - const [isLoading, setIsLoading] = useState(false); + const [ isLoading, setIsLoading ] = useState(false); + const [ debouncedQuery ] = useDebounceValue(query, 500); const getDefaultParameters = useCallback(() => ({ ParentId: parentId, - searchTerm: query, + searchTerm: debouncedQuery, Limit: 100, Fields: 'PrimaryImageAspectRatio,CanDelete,MediaSourceCount', Recursive: true, @@ -62,7 +64,7 @@ const SearchResults: FunctionComponent = ({ serverId = windo IncludeGenres: false, IncludeStudios: false, IncludeArtists: false - }), [parentId, query]); + }), [ parentId, debouncedQuery ]); const fetchArtists = useCallback((apiClient: ApiClient, params = {}) => ( apiClient?.getArtists( @@ -97,6 +99,10 @@ const SearchResults: FunctionComponent = ({ serverId = windo ).then(ensureNonNullItems) ), [getDefaultParameters]); + useEffect(() => { + if (query) setIsLoading(true); + }, [ query ]); + useEffect(() => { // Reset state setMovies([]); @@ -116,13 +122,11 @@ const SearchResults: FunctionComponent = ({ serverId = windo setPeople([]); setCollections([]); - if (!query) { + if (!debouncedQuery) { setIsLoading(false); return; } - setIsLoading(true); - const apiClient = ServerConnections.getApiClient(serverId); const fetchPromises = []; @@ -230,7 +234,7 @@ const SearchResults: FunctionComponent = ({ serverId = windo console.error('An error occurred while fetching data:', error); setIsLoading(false); // Set loading to false even if an error occurs }); - }, [collectionType, fetchArtists, fetchItems, fetchPeople, query, serverId]); + }, [collectionType, fetchArtists, fetchItems, fetchPeople, debouncedQuery, serverId]); const allEmpty = [movies, shows, episodes, videos, programs, channels, playlists, artists, albums, songs, photoAlbums, photos, audioBooks, books, people, collections].every(arr => arr.length === 0); @@ -240,7 +244,7 @@ const SearchResults: FunctionComponent = ({ serverId = windo 'searchResults', 'padded-bottom-page', 'padded-top', - { 'hide': !query || collectionType === CollectionType.Livetv } + { 'hide': !debouncedQuery || collectionType === CollectionType.Livetv } )} > {isLoading ? ( @@ -335,8 +339,8 @@ const SearchResults: FunctionComponent = ({ serverId = windo cardOptions={{ coverImage: true }} /> - {allEmpty && query && !isLoading && ( -
{globalize.translate('SearchResultsEmpty', query)}
+ {allEmpty && debouncedQuery && !isLoading && ( +
{globalize.translate('SearchResultsEmpty', debouncedQuery)}
)} )} diff --git a/src/elements/emby-input/Input.tsx b/src/elements/emby-input/Input.tsx new file mode 100644 index 0000000000..327976dc79 --- /dev/null +++ b/src/elements/emby-input/Input.tsx @@ -0,0 +1,59 @@ +import React, { type DetailedHTMLProps, type InputHTMLAttributes, type FC, useState, useCallback } from 'react'; + +import './emby-input.scss'; +import classNames from 'classnames'; + +interface InputProps extends DetailedHTMLProps, HTMLInputElement> { + id: string, + label?: string +} + +const Input: FC = ({ + id, + label, + className, + onBlur, + onFocus, + ...props +}) => { + const [ isFocused, setIsFocused ] = useState(false); + + const onBlurInternal = useCallback(e => { + setIsFocused(false); + onBlur?.(e); + }, [ onBlur ]); + + const onFocusInternal = useCallback(e => { + setIsFocused(true); + onFocus?.(e); + }, [ onFocus ]); + + return ( + <> + + + + ); +}; + +export default Input; diff --git a/src/hooks/usePrevious.ts b/src/hooks/usePrevious.ts new file mode 100644 index 0000000000..c9e74361b9 --- /dev/null +++ b/src/hooks/usePrevious.ts @@ -0,0 +1,17 @@ +import { useEffect, useRef } from 'react'; + +/** + * A hook that returns the previous value of a stateful value. + * @param value A stateful value created by a `useState` hook. + * @param initialValue The default value. + * @returns The previous value. + */ +export function usePrevious(value: T, initialValue?: T): T | undefined { + const ref = useRef(initialValue); + + useEffect(() => { + ref.current = value; + }); + + return ref.current; +}