diff --git a/packages/sanity/src/structure/panes/documentList/DocumentListPane.tsx b/packages/sanity/src/structure/panes/documentList/DocumentListPane.tsx index 61bc05af8676..d50b3562041e 100644 --- a/packages/sanity/src/structure/panes/documentList/DocumentListPane.tsx +++ b/packages/sanity/src/structure/panes/documentList/DocumentListPane.tsx @@ -1,6 +1,6 @@ import {SearchIcon, SpinnerIcon} from '@sanity/icons' import {Box, TextInput} from '@sanity/ui' -import {memo, useCallback, useEffect, useMemo, useRef, useState} from 'react' +import {memo, useCallback, useEffect, useMemo, useState} from 'react' import {useObservableEvent} from 'react-rx' import {debounce, map, type Observable, of, tap, timer} from 'rxjs' import { @@ -14,7 +14,7 @@ import {keyframes, styled} from 'styled-components' import {structureLocaleNamespace} from '../../i18n' import {type BaseStructureToolPaneProps} from '../types' -import {EMPTY_RECORD} from './constants' +import {EMPTY_RECORD, FULL_LIST_LIMIT} from './constants' import {DocumentListPaneContent} from './DocumentListPaneContent' import {applyOrderingFunctions, findStaticTypesInFilter} from './helpers' import {useShallowUnique} from './PaneContainer' @@ -38,10 +38,34 @@ const rotate = keyframes` } ` +const fadeIn = keyframes` + 0% { + opacity: 0; + } + 50% { + opacity: 0.1; + } + 100% { + opacity: 0.4; + } +` + const AnimatedSpinnerIcon = styled(SpinnerIcon)` animation: ${rotate} 500ms linear infinite; ` +const SubtleSpinnerIcon = styled(SpinnerIcon)` + animation: ${rotate} 1500ms linear infinite; + opacity: 0.4; +` + +const DelayedSubtleSpinnerIcon = styled(SpinnerIcon)` + animation: + ${rotate} 1500ms linear infinite, + ${fadeIn} 1000ms linear; + opacity: 0.4; +` + /** * @internal */ @@ -68,11 +92,6 @@ export const DocumentListPane = memo(function DocumentListPane(props: DocumentLi const [searchInputValue, setSearchInputValue] = useState('') const [searchInputElement, setSearchInputElement] = useState(null) - // A ref to determine if we should show the loading spinner in the search input. - // This is used to avoid showing the spinner on initial load of the document list. - // We only wan't to show the spinner when the user interacts with the search input. - const showSearchLoadingRef = useRef(false) - const sortWithOrderingFn = typeName && sortOrderRaw ? applyOrderingFunctions(sortOrderRaw, schema.get(typeName) as any) @@ -80,22 +99,14 @@ export const DocumentListPane = memo(function DocumentListPane(props: DocumentLi const sortOrder = useUnique(sortWithOrderingFn) - const { - error, - hasMaxItems, - isLazyLoading, - isLoading, - isSearchReady, - items, - onListChange, - onRetry, - } = useDocumentList({ - apiVersion, - filter, - params, - searchQuery: searchQuery?.trim(), - sortOrder, - }) + const {error, isLoadingFullList, isLoading, items, fromCache, onLoadFullList, onRetry} = + useDocumentList({ + apiVersion, + filter, + params, + searchQuery: searchQuery?.trim(), + sortOrder, + }) const handleQueryChange = useObservableEvent( (event$: Observable>) => { @@ -122,30 +133,41 @@ export const DocumentListPane = memo(function DocumentListPane(props: DocumentLi [handleClearSearch], ) - useEffect(() => { - if (showSearchLoadingRef.current === false && !isLoading) { - showSearchLoadingRef.current = true - } + const [enableSearchSpinner, setEnableSearchSpinner] = useState() - return () => { - showSearchLoadingRef.current = false + useEffect(() => { + if (!enableSearchSpinner && !isLoading) { + setEnableSearchSpinner(paneKey) } - }, [isLoading]) + }, [enableSearchSpinner, isLoading, paneKey]) useEffect(() => { - // Clear search field and reset showSearchLoadingRef ref + // Clear search field and disable search spinner // when switching between panes (i.e. when paneKey changes). handleClearSearch() - showSearchLoadingRef.current = false + setEnableSearchSpinner() }, [paneKey, handleClearSearch]) const loadingVariant: LoadingVariant = useMemo(() => { - const showSpinner = isLoading && items.length === 0 && showSearchLoadingRef.current - - if (showSpinner) return 'spinner' + if (isLoading && enableSearchSpinner === paneKey) { + return 'spinner' + } + if (fromCache) { + return 'subtle' + } return 'initial' - }, [isLoading, items.length]) + }, [enableSearchSpinner, fromCache, isLoading, paneKey]) + + const textInputIcon = useMemo(() => { + if (loadingVariant === 'spinner') { + return AnimatedSpinnerIcon + } + if (searchInputValue && loadingVariant === 'subtle') { + return SubtleSpinnerIcon + } + return SearchIcon + }, [loadingVariant, searchInputValue]) return ( <> @@ -155,9 +177,12 @@ export const DocumentListPane = memo(function DocumentListPane(props: DocumentLi autoComplete="off" border={false} clearButton={Boolean(searchQuery)} - disabled={!isSearchReady} + disabled={Boolean(error)} fontSize={[2, 2, 1]} - icon={loadingVariant === 'spinner' ? AnimatedSpinnerIcon : SearchIcon} + icon={textInputIcon} + iconRight={ + loadingVariant === 'subtle' && !searchInputValue ? DelayedSubtleSpinnerIcon : null + } onChange={handleQueryChange} onClear={handleClearSearch} onKeyDown={handleSearchKeyDown} @@ -173,16 +198,16 @@ export const DocumentListPane = memo(function DocumentListPane(props: DocumentLi childItemId={childItemId} error={error} filterIsSimpleTypeConstraint={!!typeName} - hasMaxItems={hasMaxItems} + hasMaxItems={items.length === FULL_LIST_LIMIT} hasSearchQuery={Boolean(searchQuery)} isActive={isActive} - isLazyLoading={isLazyLoading} + isLazyLoading={isLoadingFullList} isLoading={isLoading} items={items} key={paneKey} layout={layout} loadingVariant={loadingVariant} - onListChange={onListChange} + onEndReached={onLoadFullList} onRetry={onRetry} paneTitle={title} searchInputElement={searchInputElement} diff --git a/packages/sanity/src/structure/panes/documentList/DocumentListPaneContent.tsx b/packages/sanity/src/structure/panes/documentList/DocumentListPaneContent.tsx index 30d34900fb68..8d9a2a8c7e70 100644 --- a/packages/sanity/src/structure/panes/documentList/DocumentListPaneContent.tsx +++ b/packages/sanity/src/structure/panes/documentList/DocumentListPaneContent.tsx @@ -20,8 +20,10 @@ import {structureLocaleNamespace} from '../../i18n' import {FULL_LIST_LIMIT} from './constants' import {type DocumentListPaneItem, type LoadingVariant} from './types' -const RootBox = styled(Box)` +const RootBox = styled(Box)<{$opacity?: number}>` position: relative; + opacity: ${(props) => props.$opacity || 1}; + transition: opacity 0.4s; ` const CommandListBox = styled(Box)` @@ -44,7 +46,7 @@ interface DocumentListPaneContentProps { items: DocumentListPaneItem[] layout?: GeneralPreviewLayoutKey loadingVariant?: LoadingVariant - onListChange: () => void + onEndReached: () => void onRetry?: () => void paneTitle: string searchInputElement: HTMLInputElement | null @@ -78,7 +80,7 @@ export function DocumentListPaneContent(props: DocumentListPaneContentProps) { items, layout, loadingVariant, - onListChange, + onEndReached, onRetry, paneTitle, searchInputElement, @@ -89,14 +91,14 @@ export function DocumentListPaneContent(props: DocumentListPaneContentProps) { const {collapsed: layoutCollapsed} = usePaneLayout() const {collapsed, index} = usePane() - const [shouldRender, setShouldRender] = useState(false) + const [shouldRender, setShouldRender] = useState(!collapsed) const {t} = useTranslation(structureLocaleNamespace) const handleEndReached = useCallback(() => { - if (isLoading || isLazyLoading || !shouldRender) return - - onListChange() - }, [isLazyLoading, isLoading, onListChange, shouldRender]) + if (shouldRender) { + onEndReached() + } + }, [onEndReached, shouldRender]) useEffect(() => { if (collapsed) return undefined @@ -224,7 +226,7 @@ export function DocumentListPaneContent(props: DocumentListPaneContentProps) { const key = `${index}-${collapsed}` return ( - + = {} + +export const ENABLE_LRU_MEMO = true diff --git a/packages/sanity/src/structure/panes/documentList/listenSearchQuery.ts b/packages/sanity/src/structure/panes/documentList/listenSearchQuery.ts index 25dd3498d832..f7aafc976845 100644 --- a/packages/sanity/src/structure/panes/documentList/listenSearchQuery.ts +++ b/packages/sanity/src/structure/panes/documentList/listenSearchQuery.ts @@ -1,25 +1,29 @@ import {type SanityClient} from '@sanity/client' +import QuickLRU from 'quick-lru' import { asyncScheduler, defer, + EMPTY, map, merge, mergeMap, type Observable, of, + type OperatorFunction, partition, + pipe, share, take, throttleTime, throwError, timer, } from 'rxjs' +import {tap} from 'rxjs/operators' import {exhaustMapWithTrailing} from 'rxjs-exhaustmap-with-trailing' import {createSearch, getSearchableTypes, type SanityDocumentLike, type Schema} from 'sanity' import {getExtendedProjection} from '../../structureBuilder/util/getExtendedProjection' -// FIXME -// eslint-disable-next-line boundaries/element-types +import {ENABLE_LRU_MEMO} from './constants' import {type SortOrder} from './types' interface ListenQueryOptions { @@ -35,7 +39,12 @@ interface ListenQueryOptions { enableLegacySearch?: boolean } -export function listenSearchQuery(options: ListenQueryOptions): Observable { +export interface SearchQueryResult { + fromCache: boolean + documents: SanityDocumentLike[] +} + +export function listenSearchQuery(options: ListenQueryOptions): Observable { const { client, schema, @@ -82,6 +91,8 @@ export function listenSearchQuery(options: ListenQueryOptions): Observable ev.type === 'welcome') + const memoKey = JSON.stringify({filter, limit, params, searchQuery, sort, staticTypeNames}) + return merge( welcome$.pipe(take(1)), mutationAndReconnect$.pipe(throttleTime(1000, asyncScheduler, {leading: true, trailing: true})), @@ -146,5 +157,37 @@ export function listenSearchQuery(options: ListenQueryOptions): Observable ({ + fromCache: memo.type === 'memo', + documents: memo.value, + })), + ) + : map((documents) => ({ + fromCache: false, + documents, + })), ) } + +const lru = new QuickLRU({maxSize: 100}) +function memoLRU( + memoKey: string, + cache: QuickLRU, +): OperatorFunction { + return (input$: Observable) => + merge( + defer(() => + cache.has(memoKey) ? of({type: 'memo' as const, value: cache.get(memoKey)!}) : EMPTY, + ), + input$.pipe( + tap((result) => cache.set(memoKey, result)), + map((value) => ({ + type: 'value' as const, + value: value, + })), + ), + ) +} diff --git a/packages/sanity/src/structure/panes/documentList/types.ts b/packages/sanity/src/structure/panes/documentList/types.ts index fe6fbaa6023d..3d8e2496e7d0 100644 --- a/packages/sanity/src/structure/panes/documentList/types.ts +++ b/packages/sanity/src/structure/panes/documentList/types.ts @@ -11,10 +11,4 @@ export type SortOrder = { extendedProjection?: string } -export interface QueryResult { - error: {message: string} | null - onRetry?: () => void - result: {documents: SanityDocumentLike[]} | null -} - -export type LoadingVariant = 'spinner' | 'initial' +export type LoadingVariant = 'spinner' | 'initial' | 'subtle' diff --git a/packages/sanity/src/structure/panes/documentList/useDocumentList.ts b/packages/sanity/src/structure/panes/documentList/useDocumentList.ts index 11a10c9d4f2f..320b31dc8cfa 100644 --- a/packages/sanity/src/structure/panes/documentList/useDocumentList.ts +++ b/packages/sanity/src/structure/panes/documentList/useDocumentList.ts @@ -1,6 +1,19 @@ -import {useCallback, useEffect, useMemo, useState} from 'react' -import {concat, fromEvent, merge, of, Subject, throwError} from 'rxjs' -import {catchError, map, mergeMap, scan, startWith, take} from 'rxjs/operators' +import {observableCallback} from 'observable-callback' +import {useMemo} from 'react' +import {useObservable} from 'react-rx' +import {concat, fromEvent, merge, of} from 'rxjs' +import { + catchError, + filter, + map, + mergeMap, + scan, + share, + shareReplay, + take, + takeUntil, + withLatestFrom, +} from 'rxjs/operators' import { DEFAULT_STUDIO_CLIENT_OPTIONS, useClient, @@ -12,15 +25,7 @@ import { import {DEFAULT_ORDERING, FULL_LIST_LIMIT, PARTIAL_PAGE_LIMIT} from './constants' import {findStaticTypesInFilter, removePublishedWithDrafts} from './helpers' import {listenSearchQuery} from './listenSearchQuery' -import {type DocumentListPaneItem, type QueryResult, type SortOrder} from './types' - -const EMPTY_ARRAY: [] = [] - -const INITIAL_STATE: QueryResult = { - error: null, - onRetry: undefined, - result: null, -} +import {type DocumentListPaneItem, type SortOrder} from './types' interface UseDocumentListOpts { apiVersion?: string @@ -32,25 +37,30 @@ interface UseDocumentListOpts { interface DocumentListState { error: {message: string} | null - hasMaxItems?: boolean - isLazyLoading: boolean + isLoadingFullList: boolean isLoading: boolean - isSearchReady: boolean + fromCache?: boolean items: DocumentListPaneItem[] - onListChange: () => void - onRetry?: () => void } -const INITIAL_QUERY_RESULTS: QueryResult = { - result: null, +const INITIAL_QUERY_STATE: DocumentListState = { error: null, + isLoading: true, + isLoadingFullList: false, + fromCache: false, + items: [], +} + +interface UseDocumentListHookValue extends DocumentListState { + onRetry: () => void + onLoadFullList: () => void } /** * @internal */ -export function useDocumentList(opts: UseDocumentListOpts): DocumentListState { - const {filter, params: paramsProp, sortOrder, searchQuery, apiVersion} = opts +export function useDocumentList(opts: UseDocumentListOpts): UseDocumentListHookValue { + const {filter: searchFilter, params: paramsProp, sortOrder, searchQuery, apiVersion} = opts const client = useClient({ ...DEFAULT_STUDIO_CLIENT_OPTIONS, apiVersion: apiVersion || DEFAULT_STUDIO_CLIENT_OPTIONS.apiVersion, @@ -59,172 +69,139 @@ export function useDocumentList(opts: UseDocumentListOpts): DocumentListState { const schema = useSchema() const maxFieldDepth = useSearchMaxFieldDepth() - const [resultState, setResult] = useState(INITIAL_STATE) - const {onRetry, error, result} = resultState - - const documents = result?.documents - - // Filter out published documents that have drafts to avoid duplicates in the list. - const items = useMemo( - () => (documents ? removePublishedWithDrafts(documents) : EMPTY_ARRAY), - [documents], - ) - - // A state variable to keep track of whether we are currently lazy loading the list. - // This is used to determine whether we should show the loading spinner at the bottom of the list. - const [isLazyLoading, setIsLazyLoading] = useState(false) - - // A state to keep track of whether we have fetched the full list of documents. - const [hasFullList, setHasFullList] = useState(false) - - // A state to keep track of whether we should fetch the full list of documents. - const [shouldFetchFullList, setShouldFetchFullList] = useState(false) - // Get the type name from the filter, if it is a simple type filter. const typeNameFromFilter = useMemo( - () => findStaticTypesInFilter(filter, paramsProp), - [filter, paramsProp], + () => findStaticTypesInFilter(searchFilter, paramsProp), + [searchFilter, paramsProp], ) - // We can't have the loading state as part of the result state, since the loading - // state would be updated whenever a mutation is performed in a document in the list. - // Instead, we determine if the list is loading by checking if the result is null. - // The result is null when: - // 1. We are making the initial request - // 2. The user has performed a search or changed the sort order - const isLoading = result === null && !error - - // A flag to indicate whether we have reached the maximum number of documents. - const hasMaxItems = documents?.length === FULL_LIST_LIMIT - - // This function is triggered when the user has scrolled to the bottom of the list - // and we need to fetch more items. - const onListChange = useCallback(() => { - if (isLoading || hasFullList || shouldFetchFullList) return - - setShouldFetchFullList(true) - }, [isLoading, hasFullList, shouldFetchFullList]) - - const handleSetResult = useCallback( - (res: QueryResult) => { - if (res.error) { - setResult(res) - return - } - - const documentsLength = res.result?.documents?.length || 0 - const isLoadingMoreItems = !res.error && res?.result === null && shouldFetchFullList - - // 1. When the result is null and shouldFetchFullList is true, we are loading _more_ items. - // In this case, we want to wait for the next result and set the isLazyLoading state to true. - if (isLoadingMoreItems) { - setIsLazyLoading(true) - return - } - - // 2. If the result is not null, and less than the partial page limit, we know that - // we have fetched the full list of documents. In this case, we want to set the - // hasFullList state to true to prevent further requests. - if (documentsLength < PARTIAL_PAGE_LIMIT && documentsLength !== 0 && !shouldFetchFullList) { - setHasFullList(true) - } - - // 3. If the result is null, we are loading items. In this case, we want to - // wait for the next result. - if (res?.result === null) { - setResult((prev) => ({...(prev.error ? res : prev)})) - return - } - - // 4. Finally, set the result - setIsLazyLoading(false) - setResult(res) - }, - [shouldFetchFullList], - ) + const [onRetry$, onRetry] = useMemo(() => observableCallback(), []) + const [onFetchFullList$, onLoadFullList] = useMemo(() => observableCallback(), []) const queryResults$ = useMemo(() => { - const onRetry$ = new Subject() - const _onRetry = () => onRetry$.next() - - const limit = shouldFetchFullList ? FULL_LIST_LIMIT : PARTIAL_PAGE_LIMIT - const sort = sortOrder || DEFAULT_ORDERING - - return listenSearchQuery({ + const listenSearchQueryArgs = { client, - filter, - limit, + filter: searchFilter, + limit: PARTIAL_PAGE_LIMIT, params: paramsProp, schema, searchQuery: searchQuery || '', - sort, + sort: sortOrder || DEFAULT_ORDERING, staticTypeNames: typeNameFromFilter, maxFieldDepth, enableLegacySearch, - }).pipe( - map((results) => ({ - result: {documents: results}, - error: null, - })), - startWith(INITIAL_QUERY_RESULTS), - catchError((err) => { - if (err instanceof ProgressEvent) { - // todo: hack to work around issue with get-it (used by sanity/client) that propagates connection errors as ProgressEvent instances. This if-block can be removed once @sanity/client is par with a version of get-it that includes this fix: https://github.com/sanity-io/get-it/pull/127 - return throwError(() => new Error(`Request error`)) - } - return throwError(() => err) - }), - catchError((err, caught$) => { + } + + const partialList$ = listenSearchQuery(listenSearchQueryArgs).pipe( + shareReplay({refCount: true, bufferSize: 1}), + ) + + // we want to fetch the full list if the last result of the partial list is at the limit + const fullList$ = onFetchFullList$.pipe( + withLatestFrom(partialList$), + filter(([, result]) => result?.documents.length === PARTIAL_PAGE_LIMIT), + // we want to set up the full list listener only once + take(1), + mergeMap(() => + concat( + of({type: 'loadFullList' as const}), + listenSearchQuery({...listenSearchQueryArgs, limit: FULL_LIST_LIMIT}).pipe( + map((result) => ({type: 'result' as const, result})), + ), + ), + ), + share(), + ) + + // The combined search results from both partial page and full list + return merge( + partialList$.pipe( + map((result) => ({ + type: 'result' as const, + result, + })), + // when the full list listener kicks off, we want to stop the partial list listener + takeUntil(fullList$), + ), + fullList$, + ).pipe( + catchError((err: unknown, caught$) => { return concat( - of({result: null, error: err}), + of({type: 'error' as const, error: safeError(err)}), merge(fromEvent(window, 'online'), onRetry$).pipe( take(1), mergeMap(() => caught$), ), ) }), - scan((prev, next) => ({...prev, ...next, onRetry: _onRetry})), + scan((prev, event) => { + if (event.type === 'error') { + return { + ...prev, + error: event.error, + } + } + if (event.type === 'result') { + return { + ...prev, + error: null, + fromCache: event.result.fromCache, + isLoading: false, + items: removePublishedWithDrafts(event.result.documents), + isLoadingFullList: false, + } + } + if (event.type === 'loadFullList') { + return { + ...prev, + error: null, + isLoadingFullList: true, + } + } + throw new Error('Unexpected') + }, INITIAL_QUERY_STATE), ) }, [ - shouldFetchFullList, - sortOrder, client, - filter, + searchFilter, paramsProp, schema, searchQuery, + sortOrder, typeNameFromFilter, maxFieldDepth, enableLegacySearch, + onFetchFullList$, + onRetry$, ]) - useEffect(() => { - const sub = queryResults$.subscribe(handleSetResult) - - return () => { - sub.unsubscribe() - } - }, [handleSetResult, queryResults$]) - - const reset = useCallback(() => { - setHasFullList(false) - setIsLazyLoading(false) - setResult(INITIAL_STATE) - setShouldFetchFullList(false) - }, []) - - useEffect(() => { - reset() - }, [reset, filter, paramsProp, sortOrder, searchQuery]) + const {error, items, isLoading, fromCache, isLoadingFullList} = useObservable( + queryResults$, + INITIAL_QUERY_STATE, + ) return { error, - hasMaxItems, - isLazyLoading, + onRetry, isLoading, - isSearchReady: !error, items, - onListChange, - onRetry, + fromCache, + onLoadFullList, + isLoadingFullList, + } +} + +// todo: candidate for re-use +const nonErrorThrownWarning = `[WARNING: This was thrown as a non-error. Only Error instances should be thrown]` +function safeError(thrown: unknown): Error { + if (thrown instanceof Error) { + return thrown + } + if (typeof thrown === 'object' && thrown !== null) { + if ('message' in thrown && typeof thrown.message === 'string') { + return new Error(`${thrown.message} ${nonErrorThrownWarning}`) + } + return new Error(`${String(thrown)} ${nonErrorThrownWarning}`) } + return new Error(`${String(thrown)} ${nonErrorThrownWarning}`) }