Enhance ObjectCard and ObjectTable components with skeleton loading support
All checks were successful
farmcontrol/farmcontrol-ui/pipeline/head This commit looks good

- Added an `isSkeleton` prop to ObjectCard for conditional rendering of skeleton loaders, improving user experience during data fetching.
- Updated ObjectTable to manage skeleton boundaries, allowing for dynamic loading of pages based on visibility of skeleton cards.
- Refactored scroll handling in ObjectTable to utilize IntersectionObserver for better performance and responsiveness.
This commit is contained in:
Tom Butcher 2026-08-01 12:19:27 +01:00
parent 3c634a90d0
commit 84b7197bb9
2 changed files with 61 additions and 28 deletions

View File

@ -1,10 +1,11 @@
import { Descriptions, Card, Flex, Divider } from 'antd' import { Descriptions, Card, Flex, Divider, Skeleton } from 'antd'
import PropTypes from 'prop-types' import PropTypes from 'prop-types'
import ObjectProperty from './ObjectProperty' import ObjectProperty from './ObjectProperty'
import { createElement } from 'react' import { createElement } from 'react'
import Thumbnail from './Thumbnail' import Thumbnail from './Thumbnail'
const ObjectCard = ({ const ObjectCard = ({
isSkeleton = false,
model, model,
modelProperties, modelProperties,
visibleColumns = {}, visibleColumns = {},
@ -40,7 +41,9 @@ const ObjectCard = ({
key: prop.name, key: prop.name,
label: prop.label, label: prop.label,
span: 2, span: 2,
children: ( children: isSkeleton ? (
<Skeleton.Input active size='small' style={{ height: '24px' }} />
) : (
<ObjectProperty <ObjectProperty
{...prop} {...prop}
longId={false} longId={false}
@ -162,7 +165,8 @@ ObjectCard.propTypes = {
isEditing: PropTypes.bool, isEditing: PropTypes.bool,
rowActions: PropTypes.array, rowActions: PropTypes.array,
renderActions: PropTypes.func.isRequired, renderActions: PropTypes.func.isRequired,
cardStyle: PropTypes.string cardStyle: PropTypes.string,
isSkeleton: PropTypes.bool
} }
export default ObjectCard export default ObjectCard

View File

@ -252,7 +252,7 @@ const ObjectTable = forwardRef(
<QuestionCircleIcon /> <QuestionCircleIcon />
) )
} }
disabled={disabled} disabled={disabled || objectData?.isSkeleton}
type={'text'} type={'text'}
size={'small'} size={'small'}
onClick={() => { onClick={() => {
@ -304,6 +304,21 @@ const ObjectTable = forwardRef(
return SKELETON_HEIGHT * pageSize return SKELETON_HEIGHT * pageSize
}, [pageSize]) }, [pageSize])
const skeletonBoundaryIds = useMemo(() => {
const firstPage = pages[0]
const lastPage = pages[pages.length - 1]
return {
previous:
firstPage?.isSkeletonPage && firstPage.items.length > 0
? firstPage.items[firstPage.items.length - 1]._id
: null,
next:
lastPage?.isSkeletonPage && lastPage.items[0]
? lastPage.items[0]._id
: null
}
}, [pages])
const createPageWindow = useCallback( const createPageWindow = useCallback(
(loadedPages, direction) => { (loadedPages, direction) => {
const sortedPages = [...loadedPages].sort( const sortedPages = [...loadedPages].sort(
@ -1101,37 +1116,39 @@ const ObjectTable = forwardRef(
return () => cardsResizeObserverRef.current?.disconnect() return () => cardsResizeObserverRef.current?.disconnect()
}, []) }, [])
// Card view scroll handler // Card view: load when the leading skeleton card in a boundary page enters view
useEffect(() => { useLayoutEffect(() => {
if (!cards) return if (!cards) return
const container = cardsContainerNodeRef.current const container = cardsContainerNodeRef.current
if (!container) return if (!container) return
const scrollEl = container.querySelector('.simplebar-content-wrapper') const scrollEl = container.querySelector('.simplebar-content-wrapper')
if (!scrollEl) return if (!scrollEl) return
const handleCardsScroll = (e) => { const observer = new IntersectionObserver(
const { scrollTop, scrollHeight, clientHeight } = e.target (entries) => {
const firstPage = pagesRef.current[0] for (const entry of entries) {
const lastPage = pagesRef.current[pagesRef.current.length - 1] if (!entry.isIntersecting) continue
const boundary = entry.target.dataset.skeletonBoundary
if ( if (boundary === 'next') {
lastPage?.isSkeletonPage && loadNextPage(scrollEl)
scrollHeight - scrollTop - clientHeight < 100 && } else if (boundary === 'previous') {
!lazyLoading loadPreviousPage(scrollEl)
) { }
loadNextPage(scrollEl) }
} else if ( },
firstPage?.isSkeletonPage && {
scrollTop < 100 && root: scrollEl,
!lazyLoading rootMargin: `${SCROLL_THRESHOLD}px`,
) { threshold: 0
loadPreviousPage(scrollEl)
} }
} )
scrollEl.addEventListener('scroll', handleCardsScroll) container
return () => scrollEl.removeEventListener('scroll', handleCardsScroll) .querySelectorAll('[data-skeleton-boundary]')
}, [cards, pages, lazyLoading, loadNextPage, loadPreviousPage]) .forEach((el) => observer.observe(el))
return () => observer.disconnect()
}, [cards, skeletonBoundaryIds, loadNextPage, loadPreviousPage])
const renderCards = () => { const renderCards = () => {
return ( return (
@ -1146,9 +1163,20 @@ const ObjectTable = forwardRef(
if (record?._id == undefined) { if (record?._id == undefined) {
return null return null
} }
const skeletonBoundary =
record._id === skeletonBoundaryIds.next
? 'next'
: record._id === skeletonBoundaryIds.previous
? 'previous'
: undefined
return ( return (
<Col span={cardColSpan} key={record._id}> <Col span={cardColSpan} key={record._id}>
<div style={{ width: '100%', overflow: 'hidden' }}> <div
style={{ width: '100%', overflow: 'hidden' }}
data-skeleton-boundary={skeletonBoundary}
>
<RowForm <RowForm
record={record} record={record}
isEditing={isEditing} isEditing={isEditing}
@ -1156,6 +1184,7 @@ const ObjectTable = forwardRef(
> >
<Flex align={'center'} vertical gap={'middle'}> <Flex align={'center'} vertical gap={'middle'}>
<ObjectCard <ObjectCard
isSkeleton={record?.isSkeleton || false}
model={model} model={model}
modelProperties={modelProperties} modelProperties={modelProperties}
visibleColumns={visibleColumns} visibleColumns={visibleColumns}