-
Notifications
You must be signed in to change notification settings - Fork 43
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
✨ Use InfiniteScroller pattern for the Task Drawer (#2049)
Implement InfiniteScroller based on oVirt VmPortal implementation and use it to fetch additional tasks in the Task Drawer. Key features: 1. fetch data using useInfiniteQuery from React Query 2. track sentinel visibility using IntersectionObserver API 3. monitor item count to prevent extra fetchMore calls 4. ensure that state setter is not called on unmounted component (from IntersectionObserver callback) Resolves: #1973 Reference-Url: https://github.com/oVirt/ovirt-web-ui/blob/dfe0c4b8c92638f6e41b9fe0b09e0d07509618ae/src/components/VmsList/index.js#L50 --------- Signed-off-by: Radoslaw Szwajkowski <[email protected]> Co-authored-by: Scott Dickerson <[email protected]>
- Loading branch information
Showing
7 changed files
with
263 additions
and
65 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
6 changes: 6 additions & 0 deletions
6
client/src/app/components/InfiniteScroller/InfiniteScroller.css
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,6 @@ | ||
.infinite-scroll-sentinel { | ||
width: 100%; | ||
text-align: center; | ||
font-style: italic; | ||
font-weight: bold; | ||
} |
61 changes: 61 additions & 0 deletions
61
client/src/app/components/InfiniteScroller/InfiniteScroller.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,61 @@ | ||
import React, { ReactNode, useEffect, useRef } from "react"; | ||
import { useTranslation } from "react-i18next"; | ||
import { useVisibilityTracker } from "./useVisibilityTracker"; | ||
import "./InfiniteScroller.css"; | ||
|
||
export interface InfiniteScrollerProps { | ||
// content to display | ||
children: ReactNode; | ||
// function triggered if sentinel node is visible to load more items | ||
// returns false if call was rejected by the scheduler | ||
fetchMore: () => boolean; | ||
hasMore: boolean; | ||
// number of items currently displayed/known | ||
itemCount: number; | ||
} | ||
|
||
export const InfiniteScroller = ({ | ||
children, | ||
fetchMore, | ||
hasMore, | ||
itemCount, | ||
}: InfiniteScrollerProps) => { | ||
const { t } = useTranslation(); | ||
// Track how many items were known at time of triggering the fetch. | ||
// This allows to detect edge case when second(or more) fetchMore() is triggered before | ||
// IntersectionObserver is able to detect out-of-view event. | ||
// Initializing with zero ensures that the effect will be triggered immediately | ||
// (parent is expected to display empty state until some items are available). | ||
const itemCountRef = useRef(0); | ||
const { visible: isSentinelVisible, nodeRef: sentinelRef } = | ||
useVisibilityTracker({ | ||
enable: hasMore, | ||
}); | ||
|
||
useEffect( | ||
() => { | ||
if ( | ||
isSentinelVisible && | ||
itemCountRef.current !== itemCount && | ||
fetchMore() // fetch may be blocked if background refresh is in progress (or other manual fetch) | ||
) { | ||
// fetchMore call was triggered (it may fail but will be subject to React Query retry policy) | ||
itemCountRef.current = itemCount; | ||
} | ||
}, | ||
// reference to fetchMore() changes based on query state and ensures that the effect is triggered in the right moment | ||
// i.e. after fetch triggered by the previous fetchMore() call finished | ||
[isSentinelVisible, fetchMore, itemCount] | ||
); | ||
|
||
return ( | ||
<div> | ||
{children} | ||
{hasMore && ( | ||
<div ref={sentinelRef} className="infinite-scroll-sentinel"> | ||
{t("message.loadingTripleDot")} | ||
</div> | ||
)} | ||
</div> | ||
); | ||
}; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1 @@ | ||
export * from "./InfiniteScroller"; |
63 changes: 63 additions & 0 deletions
63
client/src/app/components/InfiniteScroller/useVisibilityTracker.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,63 @@ | ||
import { useEffect, useRef, useState, useCallback } from "react"; | ||
|
||
export function useVisibilityTracker({ enable }: { enable: boolean }) { | ||
const nodeRef = useRef<HTMLDivElement>(null); | ||
const [visible, setVisible] = useState(false); | ||
const node = nodeRef.current; | ||
|
||
// state is set from IntersectionObserver callbacks which may not align with React lifecycle | ||
// we can add extra safety by using the same approach as Console's useSafetyFirst() hook | ||
// https://github.com/openshift/console/blob/9d4a9b0a01b2de64b308f8423a325f1fae5f8726/frontend/packages/console-dynamic-plugin-sdk/src/app/components/safety-first.tsx#L10 | ||
const mounted = useRef(true); | ||
useEffect( | ||
() => () => { | ||
mounted.current = false; | ||
}, | ||
[] | ||
); | ||
const setVisibleSafe = useCallback((newValue) => { | ||
if (mounted.current) { | ||
setVisible(newValue); | ||
} | ||
}, []); | ||
|
||
useEffect(() => { | ||
if (!enable || !node) { | ||
return undefined; | ||
} | ||
|
||
// Observer with default options - the whole view port used. | ||
// Note that if root element is used then it needs to be the ancestor of the target. | ||
// In case of infinite scroller the target is always within the (scrollable!)parent | ||
// even if the node is technically hidden from the user. | ||
// https://developer.mozilla.org/en-US/docs/Web/API/Intersection_Observer_API#root | ||
const observer = new IntersectionObserver( | ||
(entries: IntersectionObserverEntry[]) => | ||
entries.forEach(({ isIntersecting }) => { | ||
if (isIntersecting) { | ||
setVisibleSafe(true); | ||
} else { | ||
setVisibleSafe(false); | ||
} | ||
}) | ||
); | ||
observer.observe(node); | ||
|
||
return () => { | ||
observer.disconnect(); | ||
setVisibleSafe(false); | ||
}; | ||
}, [enable, node, setVisibleSafe]); | ||
|
||
return { | ||
/** | ||
* Is the node referenced via `nodeRef` currently visible on the page? | ||
*/ | ||
visible, | ||
/** | ||
* A ref to a node whose visibility will be tracked. This should be set as a ref to a | ||
* relevant dom element by the component using this hook. | ||
*/ | ||
nodeRef, | ||
}; | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.